@agentunion/kite 1.3.2 → 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 +200 -0
  2. package/cli.js +76 -0
  3. package/extensions/agents/assistant/entry.py +111 -1
  4. package/extensions/agents/assistant/server.py +263 -215
  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 -215
  8. package/extensions/event_hub_bench/entry.py +107 -1
  9. package/extensions/services/backup/entry.py +299 -21
  10. package/extensions/services/backup/module.md +24 -22
  11. package/extensions/services/model_service/entry.py +145 -19
  12. package/extensions/services/model_service/module.md +21 -22
  13. package/extensions/services/watchdog/entry.py +188 -25
  14. package/extensions/services/watchdog/monitor.py +144 -34
  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/routes/schemas.py +0 -22
  28. package/extensions/services/web/server.py +421 -98
  29. package/extensions/services/web/static/css/style.css +67 -28
  30. package/extensions/services/web/static/index.html +234 -44
  31. package/extensions/services/web/static/js/app.js +1335 -48
  32. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  33. package/extensions/services/web/static/js/kernel-client.js +383 -0
  34. package/extensions/services/web/static/js/registry-tests.js +558 -0
  35. package/extensions/services/web/static/js/token-manager.js +175 -0
  36. package/extensions/services/web/static/pairing.html +248 -0
  37. package/extensions/services/web/static/test_registry.html +262 -0
  38. package/extensions/services/web/web_config.json5 +29 -0
  39. package/kernel/entry.py +120 -32
  40. package/kernel/event_hub.py +141 -16
  41. package/kernel/module.md +36 -33
  42. package/kernel/registry_store.py +48 -15
  43. package/kernel/rpc_router.py +120 -53
  44. package/kernel/server.py +219 -12
  45. package/kite_cli/__init__.py +3 -0
  46. package/kite_cli/__main__.py +5 -0
  47. package/kite_cli/commands/__init__.py +1 -0
  48. package/kite_cli/commands/clean.py +101 -0
  49. package/kite_cli/commands/doctor.py +35 -0
  50. package/kite_cli/commands/history.py +111 -0
  51. package/kite_cli/commands/info.py +96 -0
  52. package/kite_cli/commands/install.py +313 -0
  53. package/kite_cli/commands/list.py +143 -0
  54. package/kite_cli/commands/log.py +81 -0
  55. package/kite_cli/commands/rollback.py +88 -0
  56. package/kite_cli/commands/search.py +73 -0
  57. package/kite_cli/commands/uninstall.py +85 -0
  58. package/kite_cli/commands/update.py +118 -0
  59. package/kite_cli/core/__init__.py +1 -0
  60. package/kite_cli/core/checker.py +142 -0
  61. package/kite_cli/core/dependency.py +229 -0
  62. package/kite_cli/core/downloader.py +209 -0
  63. package/kite_cli/core/install_info.py +40 -0
  64. package/kite_cli/core/tool_installer.py +397 -0
  65. package/kite_cli/core/validator.py +78 -0
  66. package/kite_cli/main.py +289 -0
  67. package/kite_cli/utils/__init__.py +1 -0
  68. package/kite_cli/utils/i18n.py +252 -0
  69. package/kite_cli/utils/interactive.py +63 -0
  70. package/kite_cli/utils/operation_log.py +77 -0
  71. package/kite_cli/utils/paths.py +34 -0
  72. package/kite_cli/utils/version.py +308 -0
  73. package/launcher/entry.py +819 -158
  74. package/launcher/logging_setup.py +104 -0
  75. package/launcher/module.md +37 -37
  76. package/package.json +2 -1
  77. package/scripts/plan_manager.py +315 -0
  78. package/extensions/services/web/routes/routes_modules.py +0 -249
@@ -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,11 +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
33
- from routes.routes_modules import router as modules_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
34
41
 
35
42
  logger = logging.getLogger(__name__)
36
43
 
37
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
+
38
54
  class WebServer:
39
55
 
40
56
  def __init__(self, token: str = "", kernel_port: int = 0,
@@ -48,8 +64,10 @@ class WebServer:
48
64
  self._test_task: asyncio.Task | None = None
49
65
  self._ws: object | None = None
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,12 +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")
145
- app.include_router(modules_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)
146
213
 
147
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
148
217
  static_dir = Path(__file__).parent / "static"
149
218
  if static_dir.exists():
150
- 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)
151
254
 
152
255
  return app
153
256
 
@@ -165,6 +268,7 @@ class WebServer:
165
268
  retry_delay = 0.3 # reset on successful connection
166
269
  attempt = 0
167
270
  except asyncio.CancelledError:
271
+ print(f"[web] WS loop cancelled")
168
272
  return
169
273
  except Exception as e:
170
274
  attempt += 1
@@ -173,13 +277,25 @@ class WebServer:
173
277
  code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
174
278
  if code in (4001, 4003):
175
279
  print(f"[web] Kernel 认证失败 (code {code}),退出")
176
- import sys; sys.exit(1)
280
+ self._exit_code = 1
281
+ self._shutting_down = True
282
+ if self._uvicorn_server:
283
+ self._uvicorn_server.should_exit = True
284
+ return
177
285
  if attempt >= max_retries:
178
286
  print(f"[web] Kernel 重连失败 {max_retries} 次,退出")
179
- import sys; sys.exit(1)
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
180
295
  print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s ({attempt}/{max_retries})")
181
296
  self._ws = None
182
297
  if self._shutting_down:
298
+ print(f"[web] Shutting down, exiting WS loop")
183
299
  return
184
300
  await asyncio.sleep(retry_delay)
185
301
  retry_delay = min(retry_delay * 2, max_delay)
@@ -188,86 +304,137 @@ class WebServer:
188
304
  """Single WebSocket session: connect, register, subscribe, receive loop."""
189
305
  url = f"ws://127.0.0.1:{self.kernel_port}/ws?token={self.token}&id=web"
190
306
  print(f"[web] WS connecting to Kernel")
191
- async with websockets.connect(url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
192
- self._ws = ws
193
- elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
194
- elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
195
- print(f"[web] Connected to Kernel{elapsed_str}")
196
-
197
- # Subscribe to events
198
- await self._rpc_call(ws, "event.subscribe", {
199
- "events": [
200
- "module.started",
201
- "module.stopped",
202
- "module.shutdown",
203
- ],
204
- })
205
-
206
- # Register to Kernel Registry via RPC
207
- await self._rpc_call(ws, "registry.register", {
208
- "module_id": "web",
209
- "module_type": "service",
210
- "api_endpoint": f"http://127.0.0.1:{self.port}",
211
- "health_endpoint": "/health",
212
- "events_publish": {
213
- "web.test": {"description": "Test event from web module"},
214
- "web.started": {"description": "Web UI started with access URL"},
215
- },
216
- "events_subscribe": [
217
- "module.started",
218
- "module.stopped",
219
- "module.shutdown",
220
- ],
221
- })
222
- print(f"[web] Registered to Kernel{elapsed_str}")
223
-
224
- # Send module.ready (every reconnect, not just first time)
225
- if not self._shutting_down:
226
- await self._rpc_call(ws, "event.publish", {
227
- "event_id": str(uuid.uuid4()),
228
- "event": "module.ready",
229
- "data": {
230
- "module_id": "web",
231
- "graceful_shutdown": True,
232
- },
233
- })
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
234
310
  elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
235
311
  elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
236
- print(f"[web] module.ready sent{elapsed_str}")
237
-
238
- # Publish web.started event with access URL
239
- display_host = "localhost" if self.host == "0.0.0.0" else self.host
240
- access_url = f"http://{display_host}:{self.port}"
241
- await self._publish_event({
242
- "event": "web.started",
243
- "data": {
244
- "module_id": "web",
245
- "url": access_url,
246
- "host": self.host,
247
- "port": self.port,
248
- },
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
+ ],
249
326
  })
250
327
 
251
- # Receive loop
252
- async for raw in ws:
253
- try:
254
- msg = json.loads(raw)
255
- except (json.JSONDecodeError, TypeError):
256
- 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))
257
402
 
258
403
  try:
259
- has_method = "method" in msg
260
- has_id = "id" in msg
261
-
262
- if has_method and not has_id:
263
- # Event Notification
264
- await self._handle_event_notification(msg)
265
- elif has_method and has_id:
266
- # Incoming RPC request
267
- await self._handle_rpc_request(ws, msg)
268
- # 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}")
269
428
  except Exception as e:
270
- 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
271
438
 
272
439
  async def _rpc_call(self, ws, method: str, params: dict = None):
273
440
  """Send a JSON-RPC 2.0 request (fire-and-forget, no response awaited)."""
@@ -276,23 +443,60 @@ class WebServer:
276
443
  msg["params"] = params
277
444
  await ws.send(json.dumps(msg))
278
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
+
279
457
  async def _handle_event_notification(self, msg: dict):
280
458
  """Handle an event notification (JSON-RPC 2.0 Notification with method='event')."""
281
459
  params = msg.get("params", {})
282
460
  event_type = params.get("event", "")
283
461
  data = params.get("data", {})
284
462
 
463
+ # Log all events for debugging
464
+ print(f"[web] Event received: {event_type}, data: {data}")
465
+
285
466
  # Special handling for module.shutdown
286
467
  if event_type == "module.shutdown":
287
468
  target = data.get("module_id", "")
288
469
  reason = data.get("reason", "")
470
+ print(f"[web] Shutdown event: target={target}, reason={reason}")
289
471
  # Handle both targeted shutdown (module_id == "web") and broadcast shutdown (no module_id or launcher_lost)
290
472
  if target == "web" or not target or reason == "launcher_lost":
473
+ print(f"[web] Handling shutdown...")
291
474
  await self._handle_shutdown()
292
475
  return
476
+ else:
477
+ print(f"[web] Ignoring shutdown (not for us)")
478
+ return
293
479
 
294
- # Log other events
295
- print(f"[web] Event received: {event_type}")
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)
491
+ return
492
+
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}")
296
500
 
297
501
  async def _handle_rpc_request(self, ws, msg: dict):
298
502
  """Handle an incoming RPC request (web.* methods)."""
@@ -300,9 +504,15 @@ class WebServer:
300
504
  method = msg.get("method", "")
301
505
  params = msg.get("params", {})
302
506
 
507
+ # Strip "web." prefix if present
508
+ if method.startswith("web."):
509
+ method = method[4:]
510
+
303
511
  handlers = {
304
512
  "health": lambda p: self._rpc_health(),
305
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),
306
516
  }
307
517
  handler = handlers.get(method)
308
518
  if handler:
@@ -337,50 +547,163 @@ class WebServer:
337
547
  "uptime_seconds": round(time.time() - self._start_time),
338
548
  }
339
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
+
340
620
  async def _handle_shutdown(self):
341
- """Handle module.shutdown: exitingack → cleanup → ready → exit."""
621
+ """Handle module.shutdown: ackexiting → cleanup → ready → close connections → exit."""
622
+ print("[web] ========== SHUTDOWN STARTED ==========")
342
623
  print("[web] Received module.shutdown")
343
624
  self._shutting_down = True
344
625
 
345
- # Step 0: Send module.exiting
626
+ # Step 1: Send ack (立即确认收到)
627
+ print("[web] Sending shutdown ack...")
346
628
  await self._publish_event({
347
- "event": "module.exiting",
348
- "data": {"module_id": "web", "action": "none"},
629
+ "event": "module.shutdown.ack",
630
+ "data": {"module_id": "web"},
349
631
  })
632
+ print("[web] shutdown ack sent")
350
633
 
351
- # Step 1: Send ack
634
+ # Step 2: Send module.exiting (开始清理)
635
+ print("[web] Sending module.exiting...")
352
636
  await self._publish_event({
353
- "event": "module.shutdown.ack",
354
- "data": {"module_id": "web", "estimated_cleanup": 2},
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
+ },
355
647
  })
356
- print("[web] shutdown ack sent")
648
+ print("[web] module.exiting sent")
357
649
 
358
- # Step 2: Cleanup (cancel background tasks)
650
+ # Step 3: Cleanup (cancel background tasks)
651
+ print("[web] Cleaning up background tasks...")
359
652
  if self._test_task:
360
653
  self._test_task.cancel()
654
+ print("[web] Test task cancelled")
361
655
  if self.bt_manager:
362
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()
363
665
 
364
- # Step 3: Send ready (before closing WS!)
666
+ # Close management clients
667
+ from .routes.routes_management_ws import close_all_clients
668
+ await close_all_clients()
669
+
670
+ print("[web] All WebSocket connections closed")
671
+
672
+ # Step 4: Send ready (after closing connections)
673
+ print("[web] Sending shutdown ready...")
365
674
  await self._publish_event({
366
675
  "event": "module.shutdown.ready",
367
676
  "data": {"module_id": "web"},
368
677
  })
369
- print("[web] Shutdown complete")
678
+ print("[web] shutdown ready sent")
370
679
 
371
- # 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...")
372
682
  if self._uvicorn_server:
373
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)
374
693
 
375
694
  async def _publish_event(self, event: dict):
376
695
  """Publish an event via RPC event.publish."""
377
696
  if not self._ws:
697
+ print(f"[web] WARNING: Cannot publish event {event.get('event')}, WebSocket not connected")
378
698
  return
379
- await self._rpc_call(self._ws, "event.publish", {
380
- "event_id": str(uuid.uuid4()),
381
- "event": event.get("event", ""),
382
- "data": event.get("data", {}),
383
- })
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}")
384
707
 
385
708
  # ── Test event loop ──
386
709