@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.
- package/CHANGELOG.md +200 -0
- package/cli.js +76 -0
- package/extensions/agents/assistant/entry.py +111 -1
- package/extensions/agents/assistant/server.py +263 -215
- package/extensions/channels/acp_channel/entry.py +111 -1
- package/extensions/channels/acp_channel/module.md +23 -22
- package/extensions/channels/acp_channel/server.py +263 -215
- package/extensions/event_hub_bench/entry.py +107 -1
- package/extensions/services/backup/entry.py +299 -21
- package/extensions/services/backup/module.md +24 -22
- package/extensions/services/model_service/entry.py +145 -19
- package/extensions/services/model_service/module.md +21 -22
- package/extensions/services/watchdog/entry.py +188 -25
- package/extensions/services/watchdog/monitor.py +144 -34
- package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
- package/extensions/services/web/config_example.py +35 -0
- package/extensions/services/web/config_loader.py +110 -0
- package/extensions/services/web/entry.py +114 -26
- package/extensions/services/web/module.md +35 -24
- package/extensions/services/web/pairing.py +250 -0
- package/extensions/services/web/pairing_codes.jsonl +16 -0
- package/extensions/services/web/relay.py +643 -0
- package/extensions/services/web/relay_config.json5 +67 -0
- package/extensions/services/web/routes/routes_management_ws.py +127 -0
- package/extensions/services/web/routes/routes_rpc.py +89 -0
- package/extensions/services/web/routes/routes_test.py +61 -0
- package/extensions/services/web/routes/schemas.py +0 -22
- package/extensions/services/web/server.py +421 -98
- package/extensions/services/web/static/css/style.css +67 -28
- package/extensions/services/web/static/index.html +234 -44
- package/extensions/services/web/static/js/app.js +1335 -48
- package/extensions/services/web/static/js/kernel-client-example.js +161 -0
- package/extensions/services/web/static/js/kernel-client.js +383 -0
- package/extensions/services/web/static/js/registry-tests.js +558 -0
- package/extensions/services/web/static/js/token-manager.js +175 -0
- package/extensions/services/web/static/pairing.html +248 -0
- package/extensions/services/web/static/test_registry.html +262 -0
- package/extensions/services/web/web_config.json5 +29 -0
- package/kernel/entry.py +120 -32
- package/kernel/event_hub.py +141 -16
- package/kernel/module.md +36 -33
- package/kernel/registry_store.py +48 -15
- package/kernel/rpc_router.py +120 -53
- package/kernel/server.py +219 -12
- package/kite_cli/__init__.py +3 -0
- package/kite_cli/__main__.py +5 -0
- package/kite_cli/commands/__init__.py +1 -0
- package/kite_cli/commands/clean.py +101 -0
- package/kite_cli/commands/doctor.py +35 -0
- package/kite_cli/commands/history.py +111 -0
- package/kite_cli/commands/info.py +96 -0
- package/kite_cli/commands/install.py +313 -0
- package/kite_cli/commands/list.py +143 -0
- package/kite_cli/commands/log.py +81 -0
- package/kite_cli/commands/rollback.py +88 -0
- package/kite_cli/commands/search.py +73 -0
- package/kite_cli/commands/uninstall.py +85 -0
- package/kite_cli/commands/update.py +118 -0
- package/kite_cli/core/__init__.py +1 -0
- package/kite_cli/core/checker.py +142 -0
- package/kite_cli/core/dependency.py +229 -0
- package/kite_cli/core/downloader.py +209 -0
- package/kite_cli/core/install_info.py +40 -0
- package/kite_cli/core/tool_installer.py +397 -0
- package/kite_cli/core/validator.py +78 -0
- package/kite_cli/main.py +289 -0
- package/kite_cli/utils/__init__.py +1 -0
- package/kite_cli/utils/i18n.py +252 -0
- package/kite_cli/utils/interactive.py +63 -0
- package/kite_cli/utils/operation_log.py +77 -0
- package/kite_cli/utils/paths.py +34 -0
- package/kite_cli/utils/version.py +308 -0
- package/launcher/entry.py +819 -158
- package/launcher/logging_setup.py +104 -0
- package/launcher/module.md +37 -37
- package/package.json +2 -1
- package/scripts/plan_manager.py +315 -0
- 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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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]
|
|
237
|
-
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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]
|
|
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
|
-
#
|
|
295
|
-
|
|
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:
|
|
621
|
+
"""Handle module.shutdown: ack → exiting → 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
|
|
626
|
+
# Step 1: Send ack (立即确认收到)
|
|
627
|
+
print("[web] Sending shutdown ack...")
|
|
346
628
|
await self._publish_event({
|
|
347
|
-
"event": "module.
|
|
348
|
-
"data": {"module_id": "web"
|
|
629
|
+
"event": "module.shutdown.ack",
|
|
630
|
+
"data": {"module_id": "web"},
|
|
349
631
|
})
|
|
632
|
+
print("[web] shutdown ack sent")
|
|
350
633
|
|
|
351
|
-
# Step
|
|
634
|
+
# Step 2: Send module.exiting (开始清理)
|
|
635
|
+
print("[web] Sending module.exiting...")
|
|
352
636
|
await self._publish_event({
|
|
353
|
-
"event": "module.
|
|
354
|
-
"data": {
|
|
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]
|
|
648
|
+
print("[web] module.exiting sent")
|
|
357
649
|
|
|
358
|
-
# Step
|
|
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
|
-
#
|
|
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]
|
|
678
|
+
print("[web] shutdown ready sent")
|
|
370
679
|
|
|
371
|
-
# Step
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|