@agentunion/kite 1.0.6 → 1.2.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 (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -1,256 +1,339 @@
1
- """
2
- Registry HTTP server.
3
- 7 endpoints: /modules, /lookup, /get/{path}, /tokens, /verify, /query, /health.
4
- All endpoints except /health require Bearer token auth.
5
- Connects to Event Hub to publish module lifecycle events.
6
- """
7
-
8
- import asyncio
9
- import json
10
- import uuid
11
- from typing import Any
12
-
13
- import websockets
14
- from fastapi import FastAPI, Request, HTTPException
15
- from fastapi.responses import JSONResponse
16
-
17
- from .store import RegistryStore
18
-
19
-
20
- class RegistryServer:
21
- """FastAPI-based Registry HTTP server."""
22
-
23
- def __init__(self, store: RegistryStore, launcher_token: str = ""):
24
- self.store = store
25
- self.launcher_token = launcher_token
26
- self.app = self._create_app()
27
- self._ttl_task: asyncio.Task | None = None
28
- # Event Hub WebSocket
29
- self._event_hub_ws_url: str = ""
30
- self._ws: object | None = None
31
- self._ws_task: asyncio.Task | None = None
32
-
33
- def _extract_token(self, request: Request) -> str:
34
- """Extract Bearer token from Authorization header."""
35
- auth = request.headers.get("Authorization", "")
36
- if auth.startswith("Bearer "):
37
- return auth[7:].strip()
38
- return ""
39
-
40
- def _require_auth(self, request: Request) -> str:
41
- """Verify token, return module_id. Raise 401 on failure."""
42
- token = self._extract_token(request)
43
- module_id = self.store.verify_token(token)
44
- if module_id is None:
45
- raise HTTPException(status_code=401, detail="Invalid or missing token")
46
- return module_id
47
-
48
- def _require_launcher(self, request: Request):
49
- """Verify the caller is Launcher. Raise 403 if not."""
50
- token = self._extract_token(request)
51
- if not self.store.is_launcher(token):
52
- raise HTTPException(status_code=403, detail="Only Launcher may call this endpoint")
53
-
54
- # ── Event Hub connection ──
55
-
56
- async def _try_connect_event_hub(self):
57
- """Check if Event Hub is registered and connect if not already connected."""
58
- if self._ws:
59
- return
60
- # Look up Event Hub ws_endpoint from our own store
61
- eh = self.store.modules.get("event_hub")
62
- if not eh:
63
- return
64
- ws_url = (eh.get("metadata") or {}).get("ws_endpoint", "")
65
- if not ws_url:
66
- return
67
- self._event_hub_ws_url = ws_url
68
- if not self._ws_task:
69
- self._ws_task = asyncio.create_task(self._ws_loop())
70
-
71
- async def _ws_loop(self):
72
- """Connect to Event Hub, reconnect on failure."""
73
- while True:
74
- try:
75
- await self._ws_connect()
76
- except asyncio.CancelledError:
77
- return
78
- except Exception as e:
79
- print(f"[registry] Event Hub connection error: {e}")
80
- self._ws = None
81
- await asyncio.sleep(5)
82
-
83
- async def _ws_connect(self):
84
- """Single WebSocket session."""
85
- # Use registry's own per-module token (registered by Launcher via /tokens)
86
- # to avoid conflicting with Launcher's connection (same launcher_token → same module_id)
87
- token = self.store.token_map.get("registry", "") or self.launcher_token
88
- ws_url = f"{self._event_hub_ws_url}?token={token}"
89
- async with websockets.connect(ws_url) as ws:
90
- self._ws = ws
91
- print("[registry] Connected to Event Hub")
92
- async for raw in ws:
93
- try:
94
- msg = json.loads(raw)
95
- except (json.JSONDecodeError, TypeError):
96
- continue
97
- msg_type = msg.get("type", "")
98
- if msg_type == "error":
99
- print(f"[registry] Event Hub error: {msg.get('message')}")
100
-
101
- async def _publish_event(self, event_type: str, data: dict):
102
- """Publish event to Event Hub. Best-effort, no-op if not connected."""
103
- if not self._ws:
104
- return
105
- from datetime import datetime, timezone
106
- msg = {
107
- "type": "event",
108
- "event_id": str(uuid.uuid4()),
109
- "event": event_type,
110
- "source": "registry",
111
- "timestamp": datetime.now(timezone.utc).isoformat(),
112
- "data": data,
113
- }
114
- try:
115
- await self._ws.send(json.dumps(msg))
116
- except Exception:
117
- pass
118
-
119
- # ── App factory ──
120
-
121
- def _create_app(self) -> FastAPI:
122
- app = FastAPI(title="Kite Registry", docs_url=None, redoc_url=None)
123
- server = self
124
-
125
- @app.on_event("startup")
126
- async def _startup():
127
- server._ttl_task = asyncio.create_task(server._ttl_loop())
128
-
129
- @app.on_event("shutdown")
130
- async def _shutdown():
131
- if server._ttl_task:
132
- server._ttl_task.cancel()
133
- if server._ws_task:
134
- server._ws_task.cancel()
135
- if server._ws:
136
- await server._ws.close()
137
-
138
- # ── 1. POST /modules ──
139
-
140
- @app.post("/modules")
141
- async def modules(request: Request):
142
- caller = server._require_auth(request)
143
- body = await request.json()
144
- action = body.get("action", "")
145
-
146
- if action == "register":
147
- if "module_id" not in body:
148
- raise HTTPException(400, "module_id required")
149
- # Only Launcher or the module itself may register
150
- if caller != "launcher" and caller != body["module_id"]:
151
- raise HTTPException(403, f"Module '{caller}' cannot register as '{body['module_id']}'")
152
- result = server.store.register_module(body)
153
- if result.get("ok"):
154
- mid = body["module_id"]
155
- await server._publish_event("module.registered", {"module_id": mid})
156
- # If Event Hub just registered, try connecting
157
- if mid == "event_hub":
158
- await server._try_connect_event_hub()
159
- return result
160
-
161
- elif action == "deregister":
162
- mid = body.get("module_id")
163
- if not mid:
164
- raise HTTPException(400, "module_id required")
165
- if caller != "launcher" and caller != mid:
166
- raise HTTPException(403, f"Module '{caller}' cannot deregister '{mid}'")
167
- result = server.store.deregister_module(mid)
168
- if result.get("ok"):
169
- await server._publish_event("module.unregistered", {"module_id": mid})
170
- return result
171
-
172
- elif action == "heartbeat":
173
- mid = body.get("module_id")
174
- if not mid:
175
- raise HTTPException(400, "module_id required")
176
- if caller != "launcher" and caller != mid:
177
- raise HTTPException(403, f"Module '{caller}' cannot heartbeat for '{mid}'")
178
- result = server.store.heartbeat(mid)
179
- if result.get("ok"):
180
- await server._publish_event("module.heartbeat", {"module_id": mid})
181
- return result
182
-
183
- else:
184
- raise HTTPException(400, f"Unknown action: {action}")
185
-
186
- # ── 2. GET /lookup ──
187
-
188
- @app.get("/lookup")
189
- async def lookup(request: Request, field: str = None, module: str = None, value: str = None):
190
- server._require_auth(request)
191
- return server.store.lookup(field=field, module=module, value=value)
192
-
193
- # ── 3. GET /get/{path} ──
194
-
195
- @app.get("/get/{path:path}")
196
- async def get_by_path(request: Request, path: str):
197
- server._require_auth(request)
198
- val, found = server.store.get_by_path(path)
199
- if not found:
200
- raise HTTPException(404, f"Path not found: {path}")
201
- return val
202
-
203
- # ── 4. POST /tokens ──
204
-
205
- @app.post("/tokens")
206
- async def register_tokens(request: Request):
207
- server._require_launcher(request)
208
- body = await request.json()
209
- server.store.register_tokens(body)
210
- return {"ok": True}
211
-
212
- # ── 5. POST /verify ──
213
-
214
- @app.post("/verify")
215
- async def verify_token(request: Request):
216
- server._require_auth(request)
217
- body = await request.json()
218
- target_token = body.get("token", "")
219
- module_id = server.store.verify_token(target_token)
220
- if module_id:
221
- return {"ok": True, "module_id": module_id}
222
- return {"ok": False}
223
-
224
- # ── 6. POST /query (stub) ──
225
- # TODO: implement LLM semantic query per design §5.1
226
- # accept {"question": "..."}, search registry with LLM, return matched modules/tools
227
-
228
- @app.post("/query")
229
- async def query(request: Request):
230
- server._require_auth(request)
231
- body = await request.json()
232
- question = body.get("question", "")
233
- return {"ok": False, "error": "LLM query not implemented yet", "question": question}
234
-
235
- # ── 7. GET /health ──
236
-
237
- @app.get("/health")
238
- async def health():
239
- return {
240
- "status": "healthy",
241
- "module_count": len(server.store.modules),
242
- "online_count": sum(
243
- 1 for m in server.store.modules.values()
244
- if m.get("status") == "online"
245
- ),
246
- }
247
-
248
- return app
249
-
250
- async def _ttl_loop(self):
251
- """Periodically check heartbeat TTL and publish offline events."""
252
- while True:
253
- await asyncio.sleep(10)
254
- expired = self.store.check_ttl()
255
- for mid in expired:
256
- await self._publish_event("module.offline", {"module_id": mid})
1
+ """
2
+ Registry HTTP server.
3
+ 7 endpoints: /modules, /lookup, /get/{path}, /tokens, /verify, /query, /health.
4
+ All endpoints except /health require Bearer token auth.
5
+
6
+ Delayed ready mechanism (mechanism 7):
7
+ Registry does NOT send module.ready immediately after HTTP starts.
8
+ When Event Hub registers (POST /modules with metadata.ws_endpoint),
9
+ Registry connects to Event Hub WS and then sends module.ready.
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import uuid
15
+ from typing import Any
16
+
17
+ import websockets
18
+ from fastapi import FastAPI, Request, HTTPException
19
+ from fastapi.responses import JSONResponse
20
+
21
+ from .store import RegistryStore
22
+
23
+
24
+ class RegistryServer:
25
+ """FastAPI-based Registry HTTP server."""
26
+
27
+ def __init__(self, store: RegistryStore, launcher_token: str = "", advertise_ip: str = "127.0.0.1"):
28
+ self.store = store
29
+ self.launcher_token = launcher_token
30
+ self.advertise_ip = advertise_ip
31
+ self.port: int = 0 # set by entry.py before uvicorn.run
32
+ self.app = self._create_app()
33
+ self._ttl_task: asyncio.Task | None = None
34
+ # Event Hub WebSocket
35
+ self._event_hub_ws_url: str = ""
36
+ self._ws: object | None = None
37
+ self._ws_task: asyncio.Task | None = None
38
+ self._event_hub_connected = False
39
+ self._ready_sent = False
40
+ self._uvicorn_server = None # set by entry.py for graceful shutdown
41
+ self._shutting_down = False
42
+
43
+ def _extract_token(self, request: Request) -> str:
44
+ """Extract Bearer token from Authorization header."""
45
+ auth = request.headers.get("Authorization", "")
46
+ if auth.startswith("Bearer "):
47
+ return auth[7:].strip()
48
+ return ""
49
+
50
+ def _require_auth(self, request: Request) -> str:
51
+ """Verify token, return module_id. Raise 401 on failure."""
52
+ token = self._extract_token(request)
53
+ module_id = self.store.verify_token(token)
54
+ if module_id is None:
55
+ raise HTTPException(status_code=401, detail="Invalid or missing token")
56
+ return module_id
57
+
58
+ def _require_launcher(self, request: Request):
59
+ """Verify the caller is Launcher. Raise 403 if not."""
60
+ token = self._extract_token(request)
61
+ if not self.store.is_launcher(token):
62
+ raise HTTPException(status_code=403, detail="Only Launcher may call this endpoint")
63
+
64
+ # ── Event Hub connection + delayed ready ──
65
+
66
+ async def _try_connect_event_hub(self):
67
+ """Event Hub just registered — connect to it and send module.ready."""
68
+ if self._event_hub_connected:
69
+ return
70
+ eh = self.store.modules.get("event_hub")
71
+ if not eh:
72
+ return
73
+ ws_url = (eh.get("metadata") or {}).get("ws_endpoint", "")
74
+ if not ws_url:
75
+ return
76
+ self._event_hub_ws_url = ws_url
77
+ if not self._ws_task:
78
+ self._ws_task = asyncio.create_task(self._ws_loop())
79
+
80
+ async def _ws_loop(self):
81
+ """Connect to Event Hub, reconnect on failure."""
82
+ retry_delay = 0.5 # start with 0.5s
83
+ max_delay = 30 # cap at 30s
84
+ while not self._shutting_down:
85
+ try:
86
+ await self._ws_connect()
87
+ retry_delay = 0.5 # reset on successful connection
88
+ except asyncio.CancelledError:
89
+ return
90
+ except Exception as e:
91
+ print(f"[registry] Event Hub connection error: {e}, retrying in {retry_delay:.1f}s")
92
+ self._ws = None
93
+ self._event_hub_connected = False
94
+ if self._shutting_down:
95
+ return
96
+ await asyncio.sleep(retry_delay)
97
+ retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
98
+
99
+ async def _ws_connect(self):
100
+ """Single WebSocket session. On first connect, send module.ready."""
101
+ # Use registry's own per-module token to avoid conflicting with Launcher's connection
102
+ token = self.store.token_map.get("registry", "") or self.launcher_token
103
+ ws_url = f"{self._event_hub_ws_url}?token={token}&id=registry"
104
+ async with websockets.connect(ws_url, open_timeout=3, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
105
+ self._ws = ws
106
+ self._event_hub_connected = True
107
+ print("[registry] Connected to Event Hub")
108
+
109
+ # Subscribe to shutdown events
110
+ await ws.send(json.dumps({"type": "subscribe", "events": ["module.shutdown"]}))
111
+
112
+ # Send module.ready on first successful connection (delayed ready mechanism)
113
+ if not self._ready_sent:
114
+ self._ready_sent = True
115
+ # Self-register so watchdog (and others) can discover registry
116
+ # via /lookup. Without this, health checks fail (empty api_endpoint).
117
+ self.store.register_module({
118
+ "module_id": "registry",
119
+ "module_type": "infrastructure",
120
+ "api_endpoint": f"http://{self.advertise_ip}:{self.port}",
121
+ "health_endpoint": "/health",
122
+ })
123
+ await self._send_module_ready()
124
+
125
+ async for raw in ws:
126
+ try:
127
+ msg = json.loads(raw)
128
+ except (json.JSONDecodeError, TypeError):
129
+ continue
130
+ try:
131
+ msg_type = msg.get("type", "")
132
+ if msg_type == "event":
133
+ event = msg.get("event", "")
134
+ data = msg.get("data") if isinstance(msg.get("data"), dict) else {}
135
+ if event == "module.shutdown" and data.get("module_id") == "registry":
136
+ await self._handle_shutdown(data)
137
+ return
138
+ elif msg_type == "error":
139
+ print(f"[registry] Event Hub error: {msg.get('message')}")
140
+ except Exception as e:
141
+ print(f"[registry] 事件处理异常(已忽略): {e}")
142
+
143
+ async def _send_module_ready(self):
144
+ """Send module.ready event to Event Hub. Launcher is listening for this."""
145
+ from datetime import datetime, timezone
146
+ msg = {
147
+ "type": "event",
148
+ "event_id": str(uuid.uuid4()),
149
+ "event": "module.ready",
150
+ "source": "registry",
151
+ "timestamp": datetime.now(timezone.utc).isoformat(),
152
+ "data": {
153
+ "module_id": "registry",
154
+ "api_endpoint": f"http://{self.advertise_ip}:{self.port}",
155
+ "graceful_shutdown": True,
156
+ },
157
+ }
158
+ try:
159
+ await self._ws.send(json.dumps(msg))
160
+ print("[registry] Sent module.ready")
161
+ except Exception as e:
162
+ print(f"[registry] Failed to send module.ready: {e}")
163
+
164
+ async def _publish_event(self, event_type: str, data: dict):
165
+ """Publish event to Event Hub. Best-effort, no-op if not connected."""
166
+ if not self._ws:
167
+ return
168
+ from datetime import datetime, timezone
169
+ msg = {
170
+ "type": "event",
171
+ "event_id": str(uuid.uuid4()),
172
+ "event": event_type,
173
+ "source": "registry",
174
+ "timestamp": datetime.now(timezone.utc).isoformat(),
175
+ "data": data,
176
+ }
177
+ try:
178
+ await self._ws.send(json.dumps(msg))
179
+ except Exception:
180
+ pass
181
+
182
+ async def _handle_shutdown(self, data: dict):
183
+ """Handle module.shutdown event — ack, cleanup, ready, exit."""
184
+ print("[registry] Received shutdown request")
185
+ self._shutting_down = True
186
+ # Step 1: Send ack
187
+ await self._publish_event("module.shutdown.ack", {
188
+ "module_id": "registry",
189
+ "estimated_cleanup": 2,
190
+ })
191
+ # Step 2: Cleanup
192
+ if self._ttl_task:
193
+ self._ttl_task.cancel()
194
+ # Step 3: Send ready (before closing WS!)
195
+ await self._publish_event("module.shutdown.ready", {
196
+ "module_id": "registry",
197
+ })
198
+ print("[registry] Shutdown ready, exiting")
199
+ # Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
200
+ if self._uvicorn_server:
201
+ self._uvicorn_server.should_exit = True
202
+
203
+ # ── App factory ──
204
+
205
+ def _create_app(self) -> FastAPI:
206
+ app = FastAPI(title="Kite Registry", docs_url=None, redoc_url=None)
207
+ server = self
208
+
209
+ @app.on_event("startup")
210
+ async def _startup():
211
+ server._ttl_task = asyncio.create_task(server._ttl_loop())
212
+
213
+ @app.on_event("shutdown")
214
+ async def _shutdown():
215
+ if server._ttl_task:
216
+ server._ttl_task.cancel()
217
+ if server._ws_task:
218
+ server._ws_task.cancel()
219
+ if server._ws:
220
+ await server._ws.close()
221
+
222
+ # ── 1. POST /modules ──
223
+
224
+ @app.post("/modules")
225
+ async def modules(request: Request):
226
+ caller = server._require_auth(request)
227
+ body = await request.json()
228
+ action = body.get("action", "")
229
+
230
+ if action == "register":
231
+ if "module_id" not in body:
232
+ raise HTTPException(400, "module_id required")
233
+ # Only Launcher or the module itself may register
234
+ if caller != "launcher" and caller != body["module_id"]:
235
+ raise HTTPException(403, f"Module '{caller}' cannot register as '{body['module_id']}'")
236
+ result = server.store.register_module(body)
237
+ if result.get("ok"):
238
+ mid = body["module_id"]
239
+ await server._publish_event("module.registered", {"module_id": mid})
240
+ # If Event Hub just registered, connect and send module.ready
241
+ ws_endpoint = (body.get("metadata") or {}).get("ws_endpoint")
242
+ if ws_endpoint and mid == "event_hub":
243
+ await server._try_connect_event_hub()
244
+ return result
245
+
246
+ elif action == "deregister":
247
+ mid = body.get("module_id")
248
+ if not mid:
249
+ raise HTTPException(400, "module_id required")
250
+ if caller != "launcher" and caller != mid:
251
+ raise HTTPException(403, f"Module '{caller}' cannot deregister '{mid}'")
252
+ result = server.store.deregister_module(mid)
253
+ if result.get("ok"):
254
+ await server._publish_event("module.unregistered", {"module_id": mid})
255
+ return result
256
+
257
+ elif action == "heartbeat":
258
+ mid = body.get("module_id")
259
+ if not mid:
260
+ raise HTTPException(400, "module_id required")
261
+ if caller != "launcher" and caller != mid:
262
+ raise HTTPException(403, f"Module '{caller}' cannot heartbeat for '{mid}'")
263
+ result = server.store.heartbeat(mid)
264
+ if result.get("ok"):
265
+ await server._publish_event("module.heartbeat", {"module_id": mid})
266
+ return result
267
+
268
+ else:
269
+ raise HTTPException(400, f"Unknown action: {action}")
270
+
271
+ # ── 2. GET /lookup ──
272
+
273
+ @app.get("/lookup")
274
+ async def lookup(request: Request, field: str = None, module: str = None, value: str = None):
275
+ server._require_auth(request)
276
+ return server.store.lookup(field=field, module=module, value=value)
277
+
278
+ # ── 3. GET /get/{path} ──
279
+
280
+ @app.get("/get/{path:path}")
281
+ async def get_by_path(request: Request, path: str):
282
+ server._require_auth(request)
283
+ val, found = server.store.get_by_path(path)
284
+ if not found:
285
+ raise HTTPException(404, f"Path not found: {path}")
286
+ return val
287
+
288
+ # ── 4. POST /tokens ──
289
+
290
+ @app.post("/tokens")
291
+ async def register_tokens(request: Request):
292
+ server._require_launcher(request)
293
+ body = await request.json()
294
+ server.store.register_tokens(body)
295
+ return {"ok": True}
296
+
297
+ # ── 5. POST /verify ──
298
+
299
+ @app.post("/verify")
300
+ async def verify_token(request: Request):
301
+ server._require_auth(request)
302
+ body = await request.json()
303
+ target_token = body.get("token", "")
304
+ module_id = server.store.verify_token(target_token)
305
+ if module_id:
306
+ return {"ok": True, "module_id": module_id}
307
+ return {"ok": False}
308
+
309
+ # ── 6. POST /query (stub) ──
310
+
311
+ @app.post("/query")
312
+ async def query(request: Request):
313
+ server._require_auth(request)
314
+ body = await request.json()
315
+ question = body.get("question", "")
316
+ return {"ok": False, "error": "LLM query not implemented yet", "question": question}
317
+
318
+ # ── 7. GET /health ──
319
+
320
+ @app.get("/health")
321
+ async def health():
322
+ return {
323
+ "status": "healthy",
324
+ "module_count": len(server.store.modules),
325
+ "online_count": sum(
326
+ 1 for m in server.store.modules.values()
327
+ if m.get("status") == "online"
328
+ ),
329
+ }
330
+
331
+ return app
332
+
333
+ async def _ttl_loop(self):
334
+ """Periodically check heartbeat TTL and publish offline events."""
335
+ while True:
336
+ await asyncio.sleep(10)
337
+ expired = self.store.check_ttl()
338
+ for mid in expired:
339
+ await self._publish_event("module.offline", {"module_id": mid})