@agentunion/kite 1.3.1 → 1.4.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 (78) hide show
  1. package/CHANGELOG.md +287 -1
  2. package/cli.js +76 -0
  3. package/extensions/agents/assistant/entry.py +111 -1
  4. package/extensions/agents/assistant/server.py +263 -197
  5. package/extensions/channels/acp_channel/entry.py +111 -1
  6. package/extensions/channels/acp_channel/module.md +23 -22
  7. package/extensions/channels/acp_channel/server.py +263 -197
  8. package/extensions/event_hub_bench/entry.py +107 -1
  9. package/extensions/services/backup/entry.py +408 -72
  10. package/extensions/services/backup/module.md +24 -22
  11. package/extensions/services/model_service/entry.py +255 -71
  12. package/extensions/services/model_service/module.md +21 -22
  13. package/extensions/services/watchdog/entry.py +344 -90
  14. package/extensions/services/watchdog/monitor.py +237 -21
  15. package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
  16. package/extensions/services/web/config_example.py +35 -0
  17. package/extensions/services/web/config_loader.py +110 -0
  18. package/extensions/services/web/entry.py +114 -26
  19. package/extensions/services/web/module.md +35 -24
  20. package/extensions/services/web/pairing.py +250 -0
  21. package/extensions/services/web/pairing_codes.jsonl +16 -0
  22. package/extensions/services/web/relay.py +643 -0
  23. package/extensions/services/web/relay_config.json5 +67 -0
  24. package/extensions/services/web/routes/routes_management_ws.py +127 -0
  25. package/extensions/services/web/routes/routes_rpc.py +89 -0
  26. package/extensions/services/web/routes/routes_test.py +61 -0
  27. package/extensions/services/web/server.py +445 -99
  28. package/extensions/services/web/static/css/style.css +138 -2
  29. package/extensions/services/web/static/index.html +295 -2
  30. package/extensions/services/web/static/js/app.js +1579 -5
  31. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  32. package/extensions/services/web/static/js/kernel-client.js +383 -0
  33. package/extensions/services/web/static/js/registry-tests.js +558 -0
  34. package/extensions/services/web/static/js/token-manager.js +175 -0
  35. package/extensions/services/web/static/pairing.html +248 -0
  36. package/extensions/services/web/static/test_registry.html +262 -0
  37. package/extensions/services/web/web_config.json5 +29 -0
  38. package/kernel/entry.py +120 -32
  39. package/kernel/event_hub.py +159 -16
  40. package/kernel/module.md +36 -33
  41. package/kernel/registry_store.py +70 -20
  42. package/kernel/rpc_router.py +134 -57
  43. package/kernel/server.py +292 -15
  44. package/kite_cli/__init__.py +3 -0
  45. package/kite_cli/__main__.py +5 -0
  46. package/kite_cli/commands/__init__.py +1 -0
  47. package/kite_cli/commands/clean.py +101 -0
  48. package/kite_cli/commands/doctor.py +35 -0
  49. package/kite_cli/commands/history.py +111 -0
  50. package/kite_cli/commands/info.py +96 -0
  51. package/kite_cli/commands/install.py +313 -0
  52. package/kite_cli/commands/list.py +143 -0
  53. package/kite_cli/commands/log.py +81 -0
  54. package/kite_cli/commands/rollback.py +88 -0
  55. package/kite_cli/commands/search.py +73 -0
  56. package/kite_cli/commands/uninstall.py +85 -0
  57. package/kite_cli/commands/update.py +118 -0
  58. package/kite_cli/core/__init__.py +1 -0
  59. package/kite_cli/core/checker.py +142 -0
  60. package/kite_cli/core/dependency.py +229 -0
  61. package/kite_cli/core/downloader.py +209 -0
  62. package/kite_cli/core/install_info.py +40 -0
  63. package/kite_cli/core/tool_installer.py +397 -0
  64. package/kite_cli/core/validator.py +78 -0
  65. package/kite_cli/main.py +289 -0
  66. package/kite_cli/utils/__init__.py +1 -0
  67. package/kite_cli/utils/i18n.py +252 -0
  68. package/kite_cli/utils/interactive.py +63 -0
  69. package/kite_cli/utils/operation_log.py +77 -0
  70. package/kite_cli/utils/paths.py +34 -0
  71. package/kite_cli/utils/version.py +308 -0
  72. package/launcher/count_lines.py +34 -0
  73. package/launcher/entry.py +905 -166
  74. package/launcher/logging_setup.py +104 -0
  75. package/launcher/module.md +37 -37
  76. package/launcher/process_manager.py +12 -1
  77. package/package.json +2 -1
  78. package/scripts/plan_manager.py +315 -0
@@ -8,13 +8,14 @@ Connects to Kernel via WebSocket JSON-RPC 2.0 for event publishing and subscript
8
8
  import asyncio
9
9
  import json
10
10
  import logging
11
+ import os
11
12
  import time
12
13
  import uuid
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
16
17
  import websockets
17
- from fastapi import FastAPI
18
+ from fastapi import FastAPI, WebSocket
18
19
  from fastapi.staticfiles import StaticFiles
19
20
 
20
21
  from vendor import config as cfg
@@ -30,10 +31,26 @@ from routes.routes_contacts import router as contacts_router
30
31
  from routes.routes_stats import router as stats_router
31
32
  from routes.routes_voicechat import router as voicechat_router
32
33
  from routes.routes_devlog import router as devlog_router
34
+ from routes.routes_rpc import router as rpc_router, set_web_server
35
+ from routes.routes_management_ws import router as management_ws_router, broadcast_event
36
+ from routes.routes_test import router as test_router
37
+
38
+ from config_loader import load_business_configs
39
+ from pairing import PairingManager
40
+ from relay import KernelRelay
33
41
 
34
42
  logger = logging.getLogger(__name__)
35
43
 
36
44
 
45
+ # System broadcast events (received by all modules, may not need handling)
46
+ SYSTEM_BROADCAST_EVENTS = {
47
+ "module.ready", "module.registered", "module.started", "module.stopped",
48
+ "module.crashed", "module.exiting", "module.offline",
49
+ "module.shutdown.ack", "module.shutdown.ready",
50
+ "system.ready", "registry.updated",
51
+ }
52
+
53
+
37
54
  class WebServer:
38
55
 
39
56
  def __init__(self, token: str = "", kernel_port: int = 0,
@@ -46,10 +63,11 @@ class WebServer:
46
63
  self._ws_task: asyncio.Task | None = None
47
64
  self._test_task: asyncio.Task | None = None
48
65
  self._ws: object | None = None
49
- self._ready_sent = False
50
66
  self._shutting_down = False
67
+ self._exit_code = 0 # Exit code for main() to use
51
68
  self._uvicorn_server = None # set by entry.py for graceful shutdown
52
69
  self._start_time = time.time()
70
+ self._rpc_futures = {} # Store pending RPC request futures
53
71
  self.bt_manager: BluetoothManager | None = None
54
72
  self.task_manager: TaskManager | None = None
55
73
  self.app = self._create_app()
@@ -60,6 +78,40 @@ class WebServer:
60
78
 
61
79
  @app.on_event("startup")
62
80
  async def _startup():
81
+ # Load business configurations
82
+ module_dir = Path(__file__).parent
83
+ business_configs = load_business_configs(str(module_dir))
84
+
85
+ # Get relay service config
86
+ relay_business = business_configs.get('relay_service')
87
+ if relay_business:
88
+ relay_config = relay_business['config']
89
+
90
+ # Initialize pairing manager
91
+ auth_config = relay_config['auth']
92
+ pairing_file = module_dir / auth_config['pairing_code_file']
93
+ pairing_manager = PairingManager(
94
+ pairing_file=str(pairing_file),
95
+ code_length=auth_config['pairing_code_length'],
96
+ token_expiry=auth_config['token_expiry']
97
+ )
98
+ app.state.pairing_manager = pairing_manager
99
+ print(f"[web] Pairing manager initialized")
100
+
101
+ # Initialize relay service
102
+ relay_service = KernelRelay(
103
+ kernel_host="127.0.0.1",
104
+ kernel_port=server.kernel_port,
105
+ kernel_token=server.token,
106
+ base_module_id=relay_config['relay']['base_module_id'],
107
+ reconnect_timeout=relay_config['relay']['reconnect_timeout'],
108
+ permissions=relay_config['permissions'],
109
+ pairing_manager=pairing_manager,
110
+ web_server=server # 传递 web server 实例
111
+ )
112
+ app.state.relay_service = relay_service
113
+ print(f"[web] Relay service initialized")
114
+
63
115
  # Load configuration
64
116
  cfg.load_config()
65
117
  load_err = cfg.get_load_error()
@@ -142,11 +194,63 @@ class WebServer:
142
194
  app.include_router(stats_router, prefix="/api")
143
195
  app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
144
196
  app.include_router(devlog_router, prefix="/api")
197
+ app.include_router(rpc_router, prefix="/api")
198
+ app.include_router(test_router, prefix="/api")
199
+ app.include_router(management_ws_router) # /ws/management
200
+
201
+ # Relay WebSocket endpoint
202
+ @app.websocket("/ws/relay")
203
+ async def relay_endpoint(ws: WebSocket):
204
+ """Kernel 中转服务 WebSocket 端点"""
205
+ relay_service = app.state.relay_service
206
+ if relay_service:
207
+ await relay_service.handle_client(ws)
208
+ else:
209
+ await ws.close(code=1011, reason="Relay service not initialized")
210
+
211
+ # Set web server reference for RPC forwarding
212
+ set_web_server(server)
145
213
 
146
214
  # Serve frontend static files
215
+ # IMPORTANT: Do NOT use app.mount("/", ...) as it will intercept WebSocket routes
216
+ # Instead, explicitly serve HTML files and static assets
147
217
  static_dir = Path(__file__).parent / "static"
148
218
  if static_dir.exists():
149
- app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="web")
219
+ from fastapi.responses import FileResponse, JSONResponse
220
+
221
+ @app.get("/")
222
+ async def serve_index():
223
+ """Serve index.html at root path."""
224
+ index_path = static_dir / "index.html"
225
+ if index_path.exists():
226
+ return FileResponse(index_path)
227
+ return {"message": "Kite Web Management"}
228
+
229
+ @app.get("/pairing.html")
230
+ async def serve_pairing():
231
+ """Serve pairing.html."""
232
+ pairing_path = static_dir / "pairing.html"
233
+ if pairing_path.exists():
234
+ return FileResponse(pairing_path)
235
+ return JSONResponse({"error": "Not found"}, status_code=404)
236
+
237
+ # Mount static files at /static prefix to avoid route conflicts
238
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
239
+
240
+ # Also serve common static paths directly from root for backward compatibility
241
+ @app.get("/js/{file_path:path}")
242
+ async def serve_js(file_path: str):
243
+ file = static_dir / "js" / file_path
244
+ if file.exists() and file.is_file():
245
+ return FileResponse(file)
246
+ return JSONResponse({"error": "Not found"}, status_code=404)
247
+
248
+ @app.get("/css/{file_path:path}")
249
+ async def serve_css(file_path: str):
250
+ file = static_dir / "css" / file_path
251
+ if file.exists() and file.is_file():
252
+ return FileResponse(file)
253
+ return JSONResponse({"error": "Not found"}, status_code=404)
150
254
 
151
255
  return app
152
256
 
@@ -154,107 +258,183 @@ class WebServer:
154
258
 
155
259
  async def _ws_loop(self):
156
260
  """Connect to Kernel, subscribe, register, and listen. Reconnect on failure."""
157
- retry_delay = 0.5 # start with 0.5s
158
- max_delay = 30 # cap at 30s
261
+ retry_delay = 0.3
262
+ max_delay = 5.0
263
+ max_retries = 10
264
+ attempt = 0
159
265
  while not self._shutting_down:
160
266
  try:
161
267
  await self._ws_connect()
162
- retry_delay = 0.5 # reset on successful connection
268
+ retry_delay = 0.3 # reset on successful connection
269
+ attempt = 0
163
270
  except asyncio.CancelledError:
271
+ print(f"[web] WS loop cancelled")
164
272
  return
165
273
  except Exception as e:
166
- print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s")
274
+ attempt += 1
275
+ # Auth failure — don't retry
276
+ if hasattr(e, 'rcvd') and e.rcvd is not None:
277
+ code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
278
+ if code in (4001, 4003):
279
+ print(f"[web] Kernel 认证失败 (code {code}),退出")
280
+ self._exit_code = 1
281
+ self._shutting_down = True
282
+ if self._uvicorn_server:
283
+ self._uvicorn_server.should_exit = True
284
+ return
285
+ if attempt >= max_retries:
286
+ print(f"[web] Kernel 重连失败 {max_retries} 次,退出")
287
+ self._exit_code = 1
288
+ self._shutting_down = True
289
+ if self._uvicorn_server:
290
+ self._uvicorn_server.should_exit = True
291
+ return
292
+ if self._shutting_down:
293
+ print(f"[web] Shutting down, not retrying connection")
294
+ return
295
+ print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s ({attempt}/{max_retries})")
167
296
  self._ws = None
168
297
  if self._shutting_down:
298
+ print(f"[web] Shutting down, exiting WS loop")
169
299
  return
170
300
  await asyncio.sleep(retry_delay)
171
- retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
301
+ retry_delay = min(retry_delay * 2, max_delay)
172
302
 
173
303
  async def _ws_connect(self):
174
304
  """Single WebSocket session: connect, register, subscribe, receive loop."""
175
305
  url = f"ws://127.0.0.1:{self.kernel_port}/ws?token={self.token}&id=web"
176
306
  print(f"[web] WS connecting to Kernel")
177
- async with websockets.connect(url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
178
- self._ws = ws
179
- elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
180
- elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
181
- print(f"[web] Connected to Kernel{elapsed_str}")
182
-
183
- # Subscribe to events
184
- await self._rpc_call(ws, "event.subscribe", {
185
- "events": [
186
- "module.started",
187
- "module.stopped",
188
- "module.shutdown",
189
- ],
190
- })
191
-
192
- # Register to Kernel Registry via RPC
193
- await self._rpc_call(ws, "registry.register", {
194
- "module_id": "web",
195
- "module_type": "service",
196
- "api_endpoint": f"http://127.0.0.1:{self.port}",
197
- "health_endpoint": "/health",
198
- "events_publish": {
199
- "web.test": {"description": "Test event from web module"},
200
- "web.started": {"description": "Web UI started with access URL"},
201
- },
202
- "events_subscribe": [
203
- "module.started",
204
- "module.stopped",
205
- "module.shutdown",
206
- ],
207
- })
208
- print(f"[web] Registered to Kernel{elapsed_str}")
209
-
210
- # Send module.ready (once) so Launcher knows we're up
211
- if not self._ready_sent:
212
- await self._rpc_call(ws, "event.publish", {
213
- "event_id": str(uuid.uuid4()),
214
- "event": "module.ready",
215
- "data": {
216
- "module_id": "web",
217
- "graceful_shutdown": True,
218
- },
219
- })
220
- self._ready_sent = True
307
+ try:
308
+ async with websockets.connect(url, open_timeout=5, ping_interval=20, ping_timeout=20, close_timeout=10) as ws:
309
+ self._ws = ws
221
310
  elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
222
311
  elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
223
- print(f"[web] module.ready sent{elapsed_str}")
224
-
225
- # Publish web.started event with access URL
226
- display_host = "localhost" if self.host == "0.0.0.0" else self.host
227
- access_url = f"http://{display_host}:{self.port}"
228
- await self._publish_event({
229
- "event": "web.started",
230
- "data": {
231
- "module_id": "web",
232
- "url": access_url,
233
- "host": self.host,
234
- "port": self.port,
235
- },
312
+ print(f"[web] Connected to Kernel{elapsed_str}")
313
+
314
+ # Subscribe to events
315
+ await self._rpc_call(ws, "event.subscribe", {
316
+ "events": [
317
+ "module.started",
318
+ "module.stopped",
319
+ "module.crashed",
320
+ "module.ready",
321
+ "module.exiting",
322
+ "module.shutdown",
323
+ "module.shutdown.ack",
324
+ "module.shutdown.ready",
325
+ ],
236
326
  })
237
327
 
238
- # Receive loop
239
- async for raw in ws:
240
- try:
241
- msg = json.loads(raw)
242
- except (json.JSONDecodeError, TypeError):
243
- continue
328
+ # Register to Kernel Registry via RPC
329
+ await self._rpc_call(ws, "registry.register", {
330
+ "module_id": "web",
331
+ "module_type": "service",
332
+ "api_endpoint": f"http://127.0.0.1:{self.port}",
333
+ "health_endpoint": "/health",
334
+ "tools": {
335
+ "rpc": {
336
+ "module": {
337
+ "health": {"method": "health", "description": "健康检查"},
338
+ "status": {"method": "status", "description": "状态查询"}
339
+ },
340
+ "web": {
341
+ "list_tokens": {"method": "list_tokens", "description": "列出所有令牌"},
342
+ "revoke_token": {"method": "revoke_token", "description": "撤销令牌"}
343
+ }
344
+ }
345
+ },
346
+ "events_publish": {
347
+ "web": {
348
+ "test": {"description": "Test event from web module"},
349
+ "started": {"description": "Web UI started with access URL"},
350
+ }
351
+ },
352
+ "events_subscribe": [
353
+ "module.started",
354
+ "module.stopped",
355
+ "module.crashed",
356
+ "module.ready",
357
+ "module.exiting",
358
+ "module.shutdown",
359
+ "module.shutdown.ack",
360
+ "module.shutdown.ready",
361
+ ],
362
+ })
363
+ print(f"[web] Registered to Kernel{elapsed_str}")
364
+
365
+ # Send module.ready (every reconnect, not just first time)
366
+ if not self._shutting_down:
367
+ await self._rpc_call(ws, "event.publish", {
368
+ "event_id": str(uuid.uuid4()),
369
+ "event": "module.ready",
370
+ "data": {
371
+ "module_id": "web",
372
+ "graceful_shutdown": True,
373
+ },
374
+ })
375
+ elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
376
+ elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
377
+ print(f"[web] module.ready sent{elapsed_str}")
378
+
379
+ # Publish web.started event with access URL
380
+ display_host = "localhost" if self.host == "0.0.0.0" else self.host
381
+ access_url = f"http://{display_host}:{self.port}"
382
+ await self._publish_event({
383
+ "event": "web.started",
384
+ "data": {
385
+ "module_id": "web",
386
+ "url": access_url,
387
+ "host": self.host,
388
+ "port": self.port,
389
+ },
390
+ })
391
+
392
+ # Receive loop
393
+ # CRITICAL: RPC 死锁防范
394
+ # - 入站 RPC 请求必须用 create_task() 异步执行,不可 await
395
+ # - 原因:如果 handler 内部调用 rpc_call() 发出站请求,出站响应需要本接收循环来分发
396
+ # - 如果接收循环被 await handler 阻塞,出站响应永远收不到 → 超时死锁
397
+ # - 事件通知和 RPC 响应可以同步处理(它们不会反向调用 rpc_call)
398
+ print(f"[web] Entering receive loop")
399
+
400
+ # Start heartbeat loop
401
+ heartbeat_task = asyncio.create_task(self._heartbeat_loop(ws))
244
402
 
245
403
  try:
246
- has_method = "method" in msg
247
- has_id = "id" in msg
248
-
249
- if has_method and not has_id:
250
- # Event Notification
251
- await self._handle_event_notification(msg)
252
- elif has_method and has_id:
253
- # Incoming RPC request
254
- await self._handle_rpc_request(ws, msg)
255
- # Ignore RPC responses (we don't await them in this simple impl)
404
+ async for raw in ws:
405
+ try:
406
+ msg = json.loads(raw)
407
+ except (json.JSONDecodeError, TypeError):
408
+ continue
409
+
410
+ try:
411
+ has_method = "method" in msg
412
+ has_id = "id" in msg
413
+ has_result_or_error = "result" in msg or "error" in msg
414
+
415
+ if has_method and not has_id:
416
+ # Event Notification
417
+ await self._handle_event_notification(msg)
418
+ elif has_method and has_id:
419
+ # Incoming RPC request — run in background to prevent deadlock
420
+ asyncio.create_task(self._handle_rpc_request(ws, msg))
421
+ elif has_id and has_result_or_error:
422
+ # RPC response — resolve pending future
423
+ rpc_id = msg.get("id")
424
+ if rpc_id in self._rpc_futures:
425
+ self._rpc_futures[rpc_id].set_result(msg)
426
+ except Exception as e:
427
+ print(f"[web] 消息处理异常(已忽略): {e}")
256
428
  except Exception as e:
257
- print(f"[web] 消息处理异常(已忽略): {e}")
429
+ print(f"[web] Receive loop exited with exception: {e}")
430
+ finally:
431
+ print(f"[web] Receive loop ended")
432
+ except Exception as e:
433
+ print(f"[web] WebSocket connection error: {e}")
434
+ raise
435
+ finally:
436
+ print(f"[web] WebSocket connection closed")
437
+ self._ws = None
258
438
 
259
439
  async def _rpc_call(self, ws, method: str, params: dict = None):
260
440
  """Send a JSON-RPC 2.0 request (fire-and-forget, no response awaited)."""
@@ -263,19 +443,60 @@ class WebServer:
263
443
  msg["params"] = params
264
444
  await ws.send(json.dumps(msg))
265
445
 
446
+ async def _heartbeat_loop(self, ws):
447
+ """Send registry.heartbeat every 30 seconds to prevent TTL expiration."""
448
+ while True:
449
+ try:
450
+ await asyncio.sleep(30)
451
+ if not self._shutting_down:
452
+ await self._rpc_call(ws, "registry.heartbeat", {"module_id": "web"})
453
+ except Exception as e:
454
+ print(f"[web] Heartbeat error: {e}")
455
+ break
456
+
266
457
  async def _handle_event_notification(self, msg: dict):
267
458
  """Handle an event notification (JSON-RPC 2.0 Notification with method='event')."""
268
459
  params = msg.get("params", {})
269
460
  event_type = params.get("event", "")
270
461
  data = params.get("data", {})
271
462
 
272
- # Special handling for module.shutdown targeting web
273
- if event_type == "module.shutdown" and data.get("module_id") == "web":
274
- await self._handle_shutdown()
463
+ # Log all events for debugging
464
+ print(f"[web] Event received: {event_type}, data: {data}")
465
+
466
+ # Special handling for module.shutdown
467
+ if event_type == "module.shutdown":
468
+ target = data.get("module_id", "")
469
+ reason = data.get("reason", "")
470
+ print(f"[web] Shutdown event: target={target}, reason={reason}")
471
+ # Handle both targeted shutdown (module_id == "web") and broadcast shutdown (no module_id or launcher_lost)
472
+ if target == "web" or not target or reason == "launcher_lost":
473
+ print(f"[web] Handling shutdown...")
474
+ await self._handle_shutdown()
475
+ return
476
+ else:
477
+ print(f"[web] Ignoring shutdown (not for us)")
478
+ return
479
+
480
+ # Forward module status events to management WebSocket clients
481
+ if event_type in (
482
+ "module.started",
483
+ "module.stopped",
484
+ "module.crashed",
485
+ "module.ready",
486
+ "module.exiting",
487
+ "module.shutdown.ack",
488
+ "module.shutdown.ready",
489
+ ):
490
+ await broadcast_event(event_type, data)
275
491
  return
276
492
 
277
- # Log other events
278
- print(f"[web] Event received: {event_type}")
493
+ # Layer 2: 忽略其他系统广播事件
494
+ if event_type in SYSTEM_BROADCAST_EVENTS:
495
+ return
496
+
497
+ # Layer 3: 警告未知事件(仅开发环境)
498
+ if os.environ.get("KITE_ENV") == "development":
499
+ print(f"[web] Debug: Unhandled event: {event_type}")
279
500
 
280
501
  async def _handle_rpc_request(self, ws, msg: dict):
281
502
  """Handle an incoming RPC request (web.* methods)."""
@@ -283,9 +504,15 @@ class WebServer:
283
504
  method = msg.get("method", "")
284
505
  params = msg.get("params", {})
285
506
 
507
+ # Strip "web." prefix if present
508
+ if method.startswith("web."):
509
+ method = method[4:]
510
+
286
511
  handlers = {
287
512
  "health": lambda p: self._rpc_health(),
288
513
  "status": lambda p: self._rpc_status(),
514
+ "list_tokens": lambda p: self._rpc_list_tokens(),
515
+ "revoke_token": lambda p: self._rpc_revoke_token(p),
289
516
  }
290
517
  handler = handlers.get(method)
291
518
  if handler:
@@ -320,44 +547,163 @@ class WebServer:
320
547
  "uptime_seconds": round(time.time() - self._start_time),
321
548
  }
322
549
 
550
+ async def _rpc_list_tokens(self) -> dict:
551
+ """RPC handler for web.list_tokens."""
552
+ from extensions.services.web.pairing import PairingManager
553
+ from pathlib import Path
554
+
555
+ # Get pairing manager from app state
556
+ pairing_manager = self.app.state.pairing_manager
557
+ if not pairing_manager:
558
+ # Fallback: create temporary instance
559
+ pairing_file = Path(__file__).parent / "pairing_codes.jsonl"
560
+ pairing_manager = PairingManager(str(pairing_file))
561
+
562
+ # Read all token records
563
+ codes = pairing_manager._read_codes()
564
+
565
+ # Build a set of revoked tokens
566
+ revoked_tokens = {
567
+ record.get("token")
568
+ for record in codes
569
+ if record.get("status") == "revoked" and record.get("token")
570
+ }
571
+
572
+ # Filter only used tokens (status="used") that are not revoked
573
+ tokens = [
574
+ {
575
+ "token": record.get("token"),
576
+ "role": record.get("role", "viewer"),
577
+ "paired_at": record.get("paired_at"),
578
+ "expires_at": record.get("expires_at"),
579
+ "code": record.get("code", "")
580
+ }
581
+ for record in codes
582
+ if record.get("status") == "used"
583
+ and record.get("token")
584
+ and record.get("token") not in revoked_tokens
585
+ ]
586
+
587
+ return {"tokens": tokens}
588
+
589
+ async def _rpc_revoke_token(self, params: dict) -> dict:
590
+ """RPC handler for web.revoke_token."""
591
+ from extensions.services.web.pairing import PairingManager
592
+ from pathlib import Path
593
+
594
+ token = params.get("token")
595
+ if not token:
596
+ raise ValueError("Missing token parameter")
597
+
598
+ # Get pairing manager from app state
599
+ pairing_manager = self.app.state.pairing_manager
600
+ if not pairing_manager:
601
+ # Fallback: create temporary instance
602
+ pairing_file = Path(__file__).parent / "pairing_codes.jsonl"
603
+ pairing_manager = PairingManager(str(pairing_file))
604
+
605
+ # Verify token exists
606
+ token_info = pairing_manager.verify_token(token)
607
+ if not token_info:
608
+ raise ValueError("Token not found or already expired")
609
+
610
+ # Write a revoked record
611
+ revoked_record = {
612
+ "token": token,
613
+ "status": "revoked",
614
+ "revoked_at": datetime.now(timezone.utc).isoformat()
615
+ }
616
+ pairing_manager._write_code(revoked_record)
617
+
618
+ return {"success": True, "message": "Token revoked successfully"}
619
+
323
620
  async def _handle_shutdown(self):
324
- """Handle module.shutdown: ack → cleanup → ready → exit."""
621
+ """Handle module.shutdown: ack → exiting → cleanup → ready → close connections → exit."""
622
+ print("[web] ========== SHUTDOWN STARTED ==========")
325
623
  print("[web] Received module.shutdown")
326
624
  self._shutting_down = True
327
625
 
328
- # Step 1: Send ack
626
+ # Step 1: Send ack (立即确认收到)
627
+ print("[web] Sending shutdown ack...")
329
628
  await self._publish_event({
330
629
  "event": "module.shutdown.ack",
331
- "data": {"module_id": "web", "estimated_cleanup": 2},
630
+ "data": {"module_id": "web"},
332
631
  })
333
632
  print("[web] shutdown ack sent")
334
633
 
335
- # Step 2: Cleanup (cancel background tasks)
634
+ # Step 2: Send module.exiting (开始清理)
635
+ print("[web] Sending module.exiting...")
636
+ await self._publish_event({
637
+ "event": "module.exiting",
638
+ "data": {
639
+ "module_id": "web",
640
+ "type": "passive",
641
+ "reason": "shutdown_requested",
642
+ "restart": "auto",
643
+ "action": "none",
644
+ "timeout": 3.0,
645
+ "restart_delay": 0.0,
646
+ },
647
+ })
648
+ print("[web] module.exiting sent")
649
+
650
+ # Step 3: Cleanup (cancel background tasks)
651
+ print("[web] Cleaning up background tasks...")
336
652
  if self._test_task:
337
653
  self._test_task.cancel()
654
+ print("[web] Test task cancelled")
338
655
  if self.bt_manager:
339
656
  await self.bt_manager.stop()
657
+ print("[web] Bluetooth manager stopped")
658
+
659
+ # Step 3: Close all WebSocket connections gracefully
660
+ print("[web] Closing WebSocket connections...")
661
+
662
+ # Close relay sessions
663
+ if hasattr(self.app.state, 'relay_service'):
664
+ await self.app.state.relay_service.close_all_sessions()
665
+
666
+ # Close management clients
667
+ from .routes.routes_management_ws import close_all_clients
668
+ await close_all_clients()
340
669
 
341
- # Step 3: Send ready (before closing WS!)
670
+ print("[web] All WebSocket connections closed")
671
+
672
+ # Step 4: Send ready (after closing connections)
673
+ print("[web] Sending shutdown ready...")
342
674
  await self._publish_event({
343
675
  "event": "module.shutdown.ready",
344
676
  "data": {"module_id": "web"},
345
677
  })
346
- print("[web] Shutdown complete")
678
+ print("[web] shutdown ready sent")
347
679
 
348
- # Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
680
+ # Step 5: Trigger uvicorn graceful shutdown
681
+ print("[web] Triggering uvicorn graceful shutdown...")
349
682
  if self._uvicorn_server:
350
683
  self._uvicorn_server.should_exit = True
684
+ print("[web] uvicorn.should_exit = True")
685
+
686
+ print("[web] ========== SHUTDOWN COMPLETE ==========")
687
+
688
+ # Give uvicorn a moment to start shutdown, then force exit
689
+ # This prevents hanging on lingering connections
690
+ await asyncio.sleep(0.5)
691
+ import sys
692
+ sys.exit(0)
351
693
 
352
694
  async def _publish_event(self, event: dict):
353
695
  """Publish an event via RPC event.publish."""
354
696
  if not self._ws:
697
+ print(f"[web] WARNING: Cannot publish event {event.get('event')}, WebSocket not connected")
355
698
  return
356
- await self._rpc_call(self._ws, "event.publish", {
357
- "event_id": str(uuid.uuid4()),
358
- "event": event.get("event", ""),
359
- "data": event.get("data", {}),
360
- })
699
+ try:
700
+ await self._rpc_call(self._ws, "event.publish", {
701
+ "event_id": str(uuid.uuid4()),
702
+ "event": event.get("event", ""),
703
+ "data": event.get("data", {}),
704
+ })
705
+ except Exception as e:
706
+ print(f"[web] ERROR: Failed to publish event {event.get('event')}: {e}")
361
707
 
362
708
  # ── Test event loop ──
363
709