@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.
- package/CHANGELOG.md +287 -1
- package/cli.js +76 -0
- package/extensions/agents/assistant/entry.py +111 -1
- package/extensions/agents/assistant/server.py +263 -197
- 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 -197
- package/extensions/event_hub_bench/entry.py +107 -1
- package/extensions/services/backup/entry.py +408 -72
- package/extensions/services/backup/module.md +24 -22
- package/extensions/services/model_service/entry.py +255 -71
- package/extensions/services/model_service/module.md +21 -22
- package/extensions/services/watchdog/entry.py +344 -90
- package/extensions/services/watchdog/monitor.py +237 -21
- 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/server.py +445 -99
- package/extensions/services/web/static/css/style.css +138 -2
- package/extensions/services/web/static/index.html +295 -2
- package/extensions/services/web/static/js/app.js +1579 -5
- 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 +159 -16
- package/kernel/module.md +36 -33
- package/kernel/registry_store.py +70 -20
- package/kernel/rpc_router.py +134 -57
- package/kernel/server.py +292 -15
- 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/count_lines.py +34 -0
- package/launcher/entry.py +905 -166
- package/launcher/logging_setup.py +104 -0
- package/launcher/module.md +37 -37
- package/launcher/process_manager.py +12 -1
- package/package.json +2 -1
- 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
|
-
|
|
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.
|
|
158
|
-
max_delay =
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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]
|
|
224
|
-
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
"
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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]
|
|
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
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
#
|
|
278
|
-
|
|
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"
|
|
630
|
+
"data": {"module_id": "web"},
|
|
332
631
|
})
|
|
333
632
|
print("[web] shutdown ack sent")
|
|
334
633
|
|
|
335
|
-
# Step 2:
|
|
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
|
-
|
|
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]
|
|
678
|
+
print("[web] shutdown ready sent")
|
|
347
679
|
|
|
348
|
-
# Step
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|