@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
|
@@ -19,11 +19,126 @@ import uuid
|
|
|
19
19
|
import websockets
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
# System broadcast events (received by all modules, may not need handling)
|
|
23
|
+
SYSTEM_BROADCAST_EVENTS = {
|
|
24
|
+
"module.ready", "module.registered", "module.started", "module.stopped",
|
|
25
|
+
"module.crashed", "module.exiting", "module.offline",
|
|
26
|
+
"module.shutdown.ack", "module.shutdown.ready",
|
|
27
|
+
"system.ready", "registry.updated",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
22
31
|
# ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
|
|
23
32
|
|
|
24
33
|
|
|
25
34
|
# ── Module configuration ──
|
|
26
|
-
|
|
35
|
+
|
|
36
|
+
def _load_module_config() -> dict:
|
|
37
|
+
"""Load module configuration from module.md frontmatter.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict with keys: name, preferred_port, advertise_ip
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
SystemExit: If module.md is invalid or name is non-compliant
|
|
44
|
+
"""
|
|
45
|
+
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
46
|
+
module_md = os.path.join(_this_dir, "module.md")
|
|
47
|
+
|
|
48
|
+
# Calculate relative path for error messages
|
|
49
|
+
project_root = os.environ.get("KITE_PROJECT", "")
|
|
50
|
+
if project_root and _this_dir.startswith(project_root):
|
|
51
|
+
rel_path = os.path.relpath(_this_dir, project_root)
|
|
52
|
+
else:
|
|
53
|
+
rel_path = _this_dir
|
|
54
|
+
|
|
55
|
+
# Default values (will be overridden if valid config exists)
|
|
56
|
+
result = {
|
|
57
|
+
"name": "",
|
|
58
|
+
"preferred_port": 0,
|
|
59
|
+
"advertise_ip": "0.0.0.0"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Check if module.md exists
|
|
63
|
+
if not os.path.exists(module_md):
|
|
64
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
65
|
+
print(f" Path: {rel_path}/module.md")
|
|
66
|
+
print(f" Reason: File not found")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with open(module_md, encoding="utf-8") as f:
|
|
71
|
+
text = f.read()
|
|
72
|
+
|
|
73
|
+
# Extract YAML frontmatter (between --- markers)
|
|
74
|
+
import re
|
|
75
|
+
m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
|
|
76
|
+
if not m:
|
|
77
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
78
|
+
print(f" Path: {rel_path}/module.md")
|
|
79
|
+
print(f" Reason: Missing YAML frontmatter")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
# Parse YAML frontmatter
|
|
83
|
+
try:
|
|
84
|
+
import yaml
|
|
85
|
+
fm = yaml.safe_load(m.group(1)) or {}
|
|
86
|
+
except ImportError:
|
|
87
|
+
print(f"[{rel_path}] ERROR: PyYAML not installed, cannot parse module.md")
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
91
|
+
print(f" Path: {rel_path}/module.md")
|
|
92
|
+
print(f" Reason: YAML parse error: {e}")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
# Validate 'name' field (required)
|
|
96
|
+
if "name" not in fm:
|
|
97
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
98
|
+
print(f" Path: {rel_path}/module.md")
|
|
99
|
+
print(f" Reason: Missing 'name' field")
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
raw_name = str(fm["name"]).strip()
|
|
103
|
+
|
|
104
|
+
if not raw_name:
|
|
105
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
106
|
+
print(f" Path: {rel_path}/module.md")
|
|
107
|
+
print(f" Reason: Empty module name")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
# Validate name characters
|
|
111
|
+
sanitized = re.sub(r'[^a-zA-Z0-9_\-]', '', raw_name)
|
|
112
|
+
|
|
113
|
+
if sanitized != raw_name:
|
|
114
|
+
invalid_chars = ''.join(sorted(set(c for c in raw_name if c not in sanitized)))
|
|
115
|
+
print(f"[{rel_path}] ERROR: Invalid module configuration in module.md")
|
|
116
|
+
print(f" Path: {rel_path}/module.md")
|
|
117
|
+
print(f" Reason: Invalid characters in name '{raw_name}': {repr(invalid_chars)}")
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
result["name"] = sanitized
|
|
121
|
+
|
|
122
|
+
# Extract optional fields
|
|
123
|
+
if "preferred_port" in fm:
|
|
124
|
+
try:
|
|
125
|
+
result["preferred_port"] = int(fm["preferred_port"])
|
|
126
|
+
except (ValueError, TypeError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
if "advertise_ip" in fm:
|
|
130
|
+
result["advertise_ip"] = str(fm["advertise_ip"])
|
|
131
|
+
|
|
132
|
+
except SystemExit:
|
|
133
|
+
raise # Re-raise exit to prevent catching by outer except
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"[{rel_path}] ERROR: Failed to read module.md: {e}")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
_module_config = _load_module_config()
|
|
141
|
+
MODULE_NAME = _module_config["name"]
|
|
27
142
|
|
|
28
143
|
|
|
29
144
|
class _SafeWriter:
|
|
@@ -263,10 +378,20 @@ def _read_stdin_kite_message(expected_type: str, timeout: float = 10) -> dict |
|
|
|
263
378
|
|
|
264
379
|
# Global WS reference for publish_event callback
|
|
265
380
|
_ws_global = None
|
|
381
|
+
_shutting_down = False
|
|
382
|
+
_exit_code = 0 # Exit code for main() to use
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _is_auth_failure(e: Exception) -> bool:
|
|
386
|
+
"""Check if a WebSocket exception indicates authentication failure."""
|
|
387
|
+
if hasattr(e, 'rcvd') and e.rcvd is not None:
|
|
388
|
+
code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
|
|
389
|
+
return code in (4001, 4003)
|
|
390
|
+
return False
|
|
266
391
|
|
|
267
392
|
|
|
268
393
|
async def main():
|
|
269
|
-
global _ws_global
|
|
394
|
+
global _ws_global, _shutting_down
|
|
270
395
|
# Initialize log file paths
|
|
271
396
|
global _log_dir, _log_latest_path, _crash_log_path
|
|
272
397
|
module_data = os.environ.get("KITE_MODULE_DATA")
|
|
@@ -318,41 +443,104 @@ async def main():
|
|
|
318
443
|
|
|
319
444
|
print(f"[backup] Token received ({len(token)} chars), kernel port: {kernel_port} ({_fmt_elapsed(_t0)})")
|
|
320
445
|
|
|
321
|
-
#
|
|
446
|
+
# Start reconnect loop
|
|
447
|
+
await _ws_loop(token, kernel_port, _t0)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def _ws_loop(token: str, kernel_port: int, _t0: float):
|
|
451
|
+
"""Connect to Kernel with exponential backoff reconnection."""
|
|
452
|
+
global _shutting_down, _exit_code
|
|
453
|
+
retry_delay = 0.3
|
|
454
|
+
max_delay = 5.0
|
|
455
|
+
max_retries = 10
|
|
456
|
+
attempt = 0
|
|
457
|
+
while not _shutting_down:
|
|
458
|
+
try:
|
|
459
|
+
await _ws_connect(token, kernel_port, _t0)
|
|
460
|
+
retry_delay = 0.3
|
|
461
|
+
attempt = 0
|
|
462
|
+
except asyncio.CancelledError:
|
|
463
|
+
return
|
|
464
|
+
except Exception as e:
|
|
465
|
+
attempt += 1
|
|
466
|
+
if _is_auth_failure(e):
|
|
467
|
+
print(f"[backup] Kernel 认证失败,退出")
|
|
468
|
+
_exit_code = 1
|
|
469
|
+
_shutting_down = True
|
|
470
|
+
return
|
|
471
|
+
if attempt >= max_retries:
|
|
472
|
+
print(f"[backup] 重连失败 {max_retries} 次,退出")
|
|
473
|
+
_exit_code = 1
|
|
474
|
+
_shutting_down = True
|
|
475
|
+
return
|
|
476
|
+
_write_crash(type(e), e, e.__traceback__, severity="error", handled=True)
|
|
477
|
+
print(f"[backup] 连接错误: {e}, {retry_delay:.1f}s 后重试 ({attempt}/{max_retries})")
|
|
478
|
+
_ws_global_clear()
|
|
479
|
+
if _shutting_down:
|
|
480
|
+
return
|
|
481
|
+
await asyncio.sleep(retry_delay)
|
|
482
|
+
retry_delay = min(retry_delay * 2, max_delay)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _ws_global_clear():
|
|
486
|
+
global _ws_global
|
|
487
|
+
_ws_global = None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def _ws_connect(token: str, kernel_port: int, _t0: float):
|
|
491
|
+
"""Single WebSocket session: connect → subscribe → register → ready → receive loop."""
|
|
492
|
+
global _ws_global
|
|
493
|
+
|
|
322
494
|
ws_url = f"ws://127.0.0.1:{kernel_port}/ws?token={token}&id=backup"
|
|
323
495
|
print(f"[backup] Connecting to Kernel: {ws_url}")
|
|
324
496
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
"
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
497
|
+
async with websockets.connect(ws_url, open_timeout=5, ping_interval=20, ping_timeout=20, close_timeout=10) as ws:
|
|
498
|
+
_ws_global = ws
|
|
499
|
+
print(f"[backup] Connected to Kernel ({_fmt_elapsed(_t0)})")
|
|
500
|
+
|
|
501
|
+
# Subscribe to events
|
|
502
|
+
await _rpc_call(ws, "event.subscribe", {
|
|
503
|
+
"events": [
|
|
504
|
+
"module.started",
|
|
505
|
+
"module.stopped",
|
|
506
|
+
"module.shutdown",
|
|
507
|
+
],
|
|
508
|
+
})
|
|
509
|
+
print(f"[backup] Subscribed to events ({_fmt_elapsed(_t0)})")
|
|
510
|
+
|
|
511
|
+
# Register to Kernel Registry via RPC
|
|
512
|
+
await _rpc_call(ws, "registry.register", {
|
|
513
|
+
"module_id": "backup",
|
|
514
|
+
"module_type": "service",
|
|
515
|
+
"launcher_id": "launcher", # 声明归属的 Launcher
|
|
516
|
+
"tools": {
|
|
517
|
+
"rpc": {
|
|
518
|
+
"module": {
|
|
519
|
+
"health": {"method": "health", "description": "健康检查"},
|
|
520
|
+
"status": {"method": "status", "description": "状态查询"},
|
|
521
|
+
"config": {
|
|
522
|
+
"get": {"method": "get_settings", "description": "获取配置"},
|
|
523
|
+
"update": {"method": "update_settings", "description": "更新配置"},
|
|
524
|
+
"reset": {"method": "reset_settings", "description": "恢复默认配置"},
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
"events_publish": {
|
|
530
|
+
"backup": {
|
|
531
|
+
"test": {"description": "Test event from backup module"},
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
"events_subscribe": [
|
|
535
|
+
"module.started",
|
|
536
|
+
"module.stopped",
|
|
537
|
+
"module.shutdown",
|
|
538
|
+
],
|
|
539
|
+
})
|
|
540
|
+
print(f"[backup] Registered to Kernel ({_fmt_elapsed(_t0)})")
|
|
354
541
|
|
|
355
|
-
|
|
542
|
+
# Publish module.ready (every reconnect)
|
|
543
|
+
if not _shutting_down:
|
|
356
544
|
await _rpc_call(ws, "event.publish", {
|
|
357
545
|
"event_id": str(uuid.uuid4()),
|
|
358
546
|
"event": "module.ready",
|
|
@@ -363,34 +551,37 @@ async def main():
|
|
|
363
551
|
})
|
|
364
552
|
print(f"[backup] module.ready published ({_fmt_elapsed(_t0)})")
|
|
365
553
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
# Message loop: handle incoming RPC + events
|
|
370
|
-
async for raw in ws:
|
|
371
|
-
try:
|
|
372
|
-
msg = json.loads(raw)
|
|
373
|
-
except (json.JSONDecodeError, TypeError):
|
|
374
|
-
continue
|
|
375
|
-
|
|
376
|
-
try:
|
|
377
|
-
has_method = "method" in msg
|
|
378
|
-
has_id = "id" in msg
|
|
379
|
-
|
|
380
|
-
if has_method and not has_id:
|
|
381
|
-
# Event Notification
|
|
382
|
-
await _handle_event_notification(msg)
|
|
383
|
-
elif has_method and has_id:
|
|
384
|
-
# Incoming RPC request
|
|
385
|
-
await _handle_rpc_request(ws, msg)
|
|
386
|
-
# Ignore RPC responses (we don't await them in this simple impl)
|
|
387
|
-
except Exception as e:
|
|
388
|
-
print(f"[backup] 消息处理异常(已忽略): {e}")
|
|
554
|
+
# Start test event loop in background
|
|
555
|
+
test_task = asyncio.create_task(_test_event_loop(ws))
|
|
389
556
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
557
|
+
# Start heartbeat loop
|
|
558
|
+
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws))
|
|
559
|
+
|
|
560
|
+
# Message loop: handle incoming RPC + events
|
|
561
|
+
# CRITICAL: RPC 死锁防范
|
|
562
|
+
# - 入站 RPC 请求必须用 create_task() 异步执行,不可 await
|
|
563
|
+
# - 原因:如果 handler 内部调用 rpc_call() 发出站请求,出站响应需要本接收循环来分发
|
|
564
|
+
# - 如果接收循环被 await handler 阻塞,出站响应永远收不到 → 超时死锁
|
|
565
|
+
# - 事件通知和 RPC 响应可以同步处理(它们不会反向调用 rpc_call)
|
|
566
|
+
async for raw in ws:
|
|
567
|
+
try:
|
|
568
|
+
msg = json.loads(raw)
|
|
569
|
+
except (json.JSONDecodeError, TypeError):
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
has_method = "method" in msg
|
|
574
|
+
has_id = "id" in msg
|
|
575
|
+
|
|
576
|
+
if has_method and not has_id:
|
|
577
|
+
# Event Notification
|
|
578
|
+
await _handle_event_notification(msg)
|
|
579
|
+
elif has_method and has_id:
|
|
580
|
+
# Incoming RPC request — run in background to prevent deadlock
|
|
581
|
+
asyncio.create_task(_handle_rpc_request(ws, msg))
|
|
582
|
+
# Ignore RPC responses (we don't await them in this simple impl)
|
|
583
|
+
except Exception as e:
|
|
584
|
+
print(f"[backup] 消息处理异常(已忽略): {e}")
|
|
394
585
|
|
|
395
586
|
|
|
396
587
|
async def _rpc_call(ws, method: str, params: dict = None):
|
|
@@ -401,6 +592,18 @@ async def _rpc_call(ws, method: str, params: dict = None):
|
|
|
401
592
|
await ws.send(json.dumps(msg))
|
|
402
593
|
|
|
403
594
|
|
|
595
|
+
async def _heartbeat_loop(ws):
|
|
596
|
+
"""Send registry.heartbeat every 30 seconds to prevent TTL expiration."""
|
|
597
|
+
while True:
|
|
598
|
+
try:
|
|
599
|
+
await asyncio.sleep(30)
|
|
600
|
+
if not _shutting_down:
|
|
601
|
+
await _rpc_call(ws, "registry.heartbeat", {"module_id": "backup"})
|
|
602
|
+
except Exception as e:
|
|
603
|
+
print(f"[backup] Heartbeat error: {e}")
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
|
|
404
607
|
async def _publish_event(ws, event: dict):
|
|
405
608
|
"""Publish an event via RPC event.publish."""
|
|
406
609
|
await _rpc_call(ws, "event.publish", {
|
|
@@ -416,13 +619,22 @@ async def _handle_event_notification(msg: dict):
|
|
|
416
619
|
event_type = params.get("event", "")
|
|
417
620
|
data = params.get("data", {})
|
|
418
621
|
|
|
419
|
-
# Special handling for module.shutdown
|
|
420
|
-
if event_type == "module.shutdown"
|
|
421
|
-
|
|
622
|
+
# Special handling for module.shutdown
|
|
623
|
+
if event_type == "module.shutdown":
|
|
624
|
+
target = data.get("module_id", "")
|
|
625
|
+
reason = data.get("reason", "")
|
|
626
|
+
# Handle both targeted shutdown (module_id == "backup") and broadcast shutdown (no module_id or launcher_lost)
|
|
627
|
+
if target == "backup" or not target or reason == "launcher_lost":
|
|
628
|
+
await _handle_shutdown()
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
# Layer 2: 忽略系统广播事件
|
|
632
|
+
if event_type in SYSTEM_BROADCAST_EVENTS:
|
|
422
633
|
return
|
|
423
634
|
|
|
424
|
-
#
|
|
425
|
-
|
|
635
|
+
# Layer 3: 警告未知事件(仅开发环境)
|
|
636
|
+
if os.environ.get("KITE_ENV") == "development":
|
|
637
|
+
print(f"[backup] Debug: Unhandled event: {event_type}")
|
|
426
638
|
|
|
427
639
|
|
|
428
640
|
async def _handle_rpc_request(ws, msg: dict):
|
|
@@ -434,6 +646,9 @@ async def _handle_rpc_request(ws, msg: dict):
|
|
|
434
646
|
handlers = {
|
|
435
647
|
"health": lambda p: _rpc_health(),
|
|
436
648
|
"status": lambda p: _rpc_status(),
|
|
649
|
+
"get_settings": lambda p: _rpc_get_settings(p),
|
|
650
|
+
"update_settings": lambda p: _rpc_update_settings(p),
|
|
651
|
+
"reset_settings": lambda p: _rpc_reset_settings(p),
|
|
437
652
|
}
|
|
438
653
|
handler = handlers.get(method)
|
|
439
654
|
if handler:
|
|
@@ -471,23 +686,144 @@ async def _rpc_status() -> dict:
|
|
|
471
686
|
}
|
|
472
687
|
|
|
473
688
|
|
|
689
|
+
# ── Configuration management helpers ──
|
|
690
|
+
|
|
691
|
+
def _read_module_md() -> tuple[dict, str]:
|
|
692
|
+
"""读取 module.md,返回 (frontmatter, body)"""
|
|
693
|
+
from pathlib import Path
|
|
694
|
+
md_path = Path(__file__).parent / "module.md"
|
|
695
|
+
text = md_path.read_text(encoding="utf-8")
|
|
696
|
+
|
|
697
|
+
# 提取 YAML frontmatter (--- ... ---)
|
|
698
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)', text, re.DOTALL)
|
|
699
|
+
if not m:
|
|
700
|
+
return {}, text
|
|
701
|
+
|
|
702
|
+
import yaml
|
|
703
|
+
frontmatter = yaml.safe_load(m.group(1)) or {}
|
|
704
|
+
body = m.group(2)
|
|
705
|
+
return frontmatter, body
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _write_module_md(frontmatter: dict, body: str):
|
|
709
|
+
"""写入 module.md"""
|
|
710
|
+
from pathlib import Path
|
|
711
|
+
import yaml
|
|
712
|
+
md_path = Path(__file__).parent / "module.md"
|
|
713
|
+
fm_str = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False).rstrip()
|
|
714
|
+
content = f"---\n{fm_str}\n---\n{body}"
|
|
715
|
+
md_path.write_text(content, encoding="utf-8")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ── RPC handlers for configuration management ──
|
|
719
|
+
|
|
720
|
+
async def _rpc_get_settings(params: dict) -> dict:
|
|
721
|
+
"""RPC handler for backup.get_settings — 获取模块的所有设置"""
|
|
722
|
+
frontmatter, _ = _read_module_md()
|
|
723
|
+
return {
|
|
724
|
+
"name": frontmatter.get("name", "backup"),
|
|
725
|
+
"display_name": frontmatter.get("display_name", ""),
|
|
726
|
+
"type": frontmatter.get("type", ""),
|
|
727
|
+
"state": frontmatter.get("state", "enabled"),
|
|
728
|
+
"version": frontmatter.get("version", ""),
|
|
729
|
+
"runtime": frontmatter.get("runtime", ""),
|
|
730
|
+
"entry": frontmatter.get("entry", ""),
|
|
731
|
+
"preferred_port": frontmatter.get("preferred_port"),
|
|
732
|
+
"advertise_ip": frontmatter.get("advertise_ip"),
|
|
733
|
+
"monitor": frontmatter.get("monitor"),
|
|
734
|
+
"events": frontmatter.get("events"),
|
|
735
|
+
"subscriptions": frontmatter.get("subscriptions"),
|
|
736
|
+
"depends_on": frontmatter.get("depends_on"),
|
|
737
|
+
"has_config": False, # backup 模块暂无 config.yaml
|
|
738
|
+
"config": None,
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
async def _rpc_update_settings(params: dict) -> dict:
|
|
743
|
+
"""RPC handler for backup.update_settings — 更新模块设置"""
|
|
744
|
+
metadata = params.get("metadata", {})
|
|
745
|
+
config = params.get("config", {})
|
|
746
|
+
|
|
747
|
+
# 更新 module.md frontmatter
|
|
748
|
+
if metadata:
|
|
749
|
+
frontmatter, body = _read_module_md()
|
|
750
|
+
for key, value in metadata.items():
|
|
751
|
+
frontmatter[key] = value
|
|
752
|
+
_write_module_md(frontmatter, body)
|
|
753
|
+
|
|
754
|
+
# 更新 config.yaml(如果需要)
|
|
755
|
+
if config:
|
|
756
|
+
# backup 模块暂无 config.yaml,此处预留接口
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
# 返回更新后的完整设置
|
|
760
|
+
return await _rpc_get_settings({})
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
async def _rpc_reset_settings(params: dict) -> dict:
|
|
764
|
+
"""RPC handler for backup.reset_settings — 恢复默认值"""
|
|
765
|
+
fields = params.get("fields", [])
|
|
766
|
+
reset_all = params.get("all", False)
|
|
767
|
+
|
|
768
|
+
# 默认值定义
|
|
769
|
+
defaults = {
|
|
770
|
+
"state": "enabled",
|
|
771
|
+
"preferred_port": 20000,
|
|
772
|
+
"advertise_ip": "127.0.0.1",
|
|
773
|
+
"monitor": True,
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
frontmatter, body = _read_module_md()
|
|
777
|
+
|
|
778
|
+
if reset_all:
|
|
779
|
+
# 恢复所有字段
|
|
780
|
+
for key, value in defaults.items():
|
|
781
|
+
frontmatter[key] = value
|
|
782
|
+
else:
|
|
783
|
+
# 恢复指定字段
|
|
784
|
+
for field in fields:
|
|
785
|
+
if field in defaults:
|
|
786
|
+
frontmatter[field] = defaults[field]
|
|
787
|
+
|
|
788
|
+
_write_module_md(frontmatter, body)
|
|
789
|
+
|
|
790
|
+
# 返回恢复后的设置
|
|
791
|
+
return await _rpc_get_settings({})
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
|
|
474
795
|
async def _handle_shutdown():
|
|
475
|
-
"""Handle module.shutdown event — ack
|
|
796
|
+
"""Handle module.shutdown event — ack → exiting → cleanup → ready → exit."""
|
|
797
|
+
global _shutting_down
|
|
476
798
|
print("[backup] Received shutdown request")
|
|
477
|
-
|
|
799
|
+
_shutting_down = True
|
|
800
|
+
# Step 1: Send ack (立即确认收到)
|
|
478
801
|
await _publish_event(_ws_global, {
|
|
479
802
|
"event": "module.shutdown.ack",
|
|
480
|
-
"data": {"module_id": "backup"
|
|
803
|
+
"data": {"module_id": "backup"},
|
|
804
|
+
})
|
|
805
|
+
# Step 2: Send module.exiting (开始清理)
|
|
806
|
+
await _publish_event(_ws_global, {
|
|
807
|
+
"event": "module.exiting",
|
|
808
|
+
"data": {
|
|
809
|
+
"module_id": "backup",
|
|
810
|
+
"type": "passive",
|
|
811
|
+
"reason": "shutdown_requested",
|
|
812
|
+
"restart": "auto",
|
|
813
|
+
"action": "none",
|
|
814
|
+
"timeout": 2.0,
|
|
815
|
+
"restart_delay": 0.0,
|
|
816
|
+
},
|
|
481
817
|
})
|
|
482
|
-
# Step
|
|
483
|
-
# Step
|
|
818
|
+
# Step 3: Cleanup (nothing to clean up for backup)
|
|
819
|
+
# Step 4: Send ready (清理完成)
|
|
484
820
|
await _publish_event(_ws_global, {
|
|
485
821
|
"event": "module.shutdown.ready",
|
|
486
822
|
"data": {"module_id": "backup"},
|
|
487
823
|
})
|
|
488
824
|
print("[backup] Shutdown ready, exiting")
|
|
489
|
-
# Step
|
|
490
|
-
sys.exit(
|
|
825
|
+
# Step 5: Exit
|
|
826
|
+
sys.exit(_exit_code)
|
|
491
827
|
|
|
492
828
|
|
|
493
829
|
async def _test_event_loop(ws):
|
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: backup
|
|
3
|
-
display_name: Backup
|
|
4
|
-
version:
|
|
5
|
-
type: service
|
|
6
|
-
state: enabled
|
|
7
|
-
runtime: python
|
|
8
|
-
entry: entry.py
|
|
9
|
-
events:
|
|
10
|
-
|
|
11
|
-
subscriptions:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
---
|
|
2
|
+
name: backup
|
|
3
|
+
display_name: Backup
|
|
4
|
+
version: '1.0'
|
|
5
|
+
type: service
|
|
6
|
+
state: enabled
|
|
7
|
+
runtime: python
|
|
8
|
+
entry: entry.py
|
|
9
|
+
events:
|
|
10
|
+
- backup.test
|
|
11
|
+
subscriptions:
|
|
12
|
+
- module.started
|
|
13
|
+
- module.stopped
|
|
14
|
+
- module.shutdown
|
|
15
|
+
preferred_port: 20000
|
|
16
|
+
advertise_ip: 127.0.0.1
|
|
17
|
+
monitor: true
|
|
18
|
+
---
|
|
19
|
+
# Backup(备份模块)
|
|
20
|
+
|
|
21
|
+
自动备份工程代码和数据的扩展模块。
|
|
22
|
+
|
|
23
|
+
- 定时备份 — 按配置周期自动备份指定目录
|
|
24
|
+
- 事件通知 — 通过 Event Hub 发布备份状态事件
|