@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
@@ -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
- MODULE_NAME = "backup"
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:
@@ -264,6 +379,7 @@ def _read_stdin_kite_message(expected_type: str, timeout: float = 10) -> dict |
264
379
  # Global WS reference for publish_event callback
265
380
  _ws_global = None
266
381
  _shutting_down = False
382
+ _exit_code = 0 # Exit code for main() to use
267
383
 
268
384
 
269
385
  def _is_auth_failure(e: Exception) -> bool:
@@ -333,7 +449,7 @@ async def main():
333
449
 
334
450
  async def _ws_loop(token: str, kernel_port: int, _t0: float):
335
451
  """Connect to Kernel with exponential backoff reconnection."""
336
- global _shutting_down
452
+ global _shutting_down, _exit_code
337
453
  retry_delay = 0.3
338
454
  max_delay = 5.0
339
455
  max_retries = 10
@@ -349,10 +465,14 @@ async def _ws_loop(token: str, kernel_port: int, _t0: float):
349
465
  attempt += 1
350
466
  if _is_auth_failure(e):
351
467
  print(f"[backup] Kernel 认证失败,退出")
352
- sys.exit(1)
468
+ _exit_code = 1
469
+ _shutting_down = True
470
+ return
353
471
  if attempt >= max_retries:
354
472
  print(f"[backup] 重连失败 {max_retries} 次,退出")
355
- sys.exit(1)
473
+ _exit_code = 1
474
+ _shutting_down = True
475
+ return
356
476
  _write_crash(type(e), e, e.__traceback__, severity="error", handled=True)
357
477
  print(f"[backup] 连接错误: {e}, {retry_delay:.1f}s 后重试 ({attempt}/{max_retries})")
358
478
  _ws_global_clear()
@@ -374,7 +494,7 @@ async def _ws_connect(token: str, kernel_port: int, _t0: float):
374
494
  ws_url = f"ws://127.0.0.1:{kernel_port}/ws?token={token}&id=backup"
375
495
  print(f"[backup] Connecting to Kernel: {ws_url}")
376
496
 
377
- async with websockets.connect(ws_url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
497
+ async with websockets.connect(ws_url, open_timeout=5, ping_interval=20, ping_timeout=20, close_timeout=10) as ws:
378
498
  _ws_global = ws
379
499
  print(f"[backup] Connected to Kernel ({_fmt_elapsed(_t0)})")
380
500
 
@@ -392,8 +512,24 @@ async def _ws_connect(token: str, kernel_port: int, _t0: float):
392
512
  await _rpc_call(ws, "registry.register", {
393
513
  "module_id": "backup",
394
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
+ },
395
529
  "events_publish": {
396
- "backup.test": {"description": "Test event from backup module"},
530
+ "backup": {
531
+ "test": {"description": "Test event from backup module"},
532
+ }
397
533
  },
398
534
  "events_subscribe": [
399
535
  "module.started",
@@ -418,7 +554,15 @@ async def _ws_connect(token: str, kernel_port: int, _t0: float):
418
554
  # Start test event loop in background
419
555
  test_task = asyncio.create_task(_test_event_loop(ws))
420
556
 
557
+ # Start heartbeat loop
558
+ heartbeat_task = asyncio.create_task(_heartbeat_loop(ws))
559
+
421
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)
422
566
  async for raw in ws:
423
567
  try:
424
568
  msg = json.loads(raw)
@@ -433,8 +577,8 @@ async def _ws_connect(token: str, kernel_port: int, _t0: float):
433
577
  # Event Notification
434
578
  await _handle_event_notification(msg)
435
579
  elif has_method and has_id:
436
- # Incoming RPC request
437
- await _handle_rpc_request(ws, msg)
580
+ # Incoming RPC request — run in background to prevent deadlock
581
+ asyncio.create_task(_handle_rpc_request(ws, msg))
438
582
  # Ignore RPC responses (we don't await them in this simple impl)
439
583
  except Exception as e:
440
584
  print(f"[backup] 消息处理异常(已忽略): {e}")
@@ -448,6 +592,18 @@ async def _rpc_call(ws, method: str, params: dict = None):
448
592
  await ws.send(json.dumps(msg))
449
593
 
450
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
+
451
607
  async def _publish_event(ws, event: dict):
452
608
  """Publish an event via RPC event.publish."""
453
609
  await _rpc_call(ws, "event.publish", {
@@ -472,8 +628,13 @@ async def _handle_event_notification(msg: dict):
472
628
  await _handle_shutdown()
473
629
  return
474
630
 
475
- # Log other events
476
- print(f"[backup] Event received: {event_type}")
631
+ # Layer 2: 忽略系统广播事件
632
+ if event_type in SYSTEM_BROADCAST_EVENTS:
633
+ return
634
+
635
+ # Layer 3: 警告未知事件(仅开发环境)
636
+ if os.environ.get("KITE_ENV") == "development":
637
+ print(f"[backup] Debug: Unhandled event: {event_type}")
477
638
 
478
639
 
479
640
  async def _handle_rpc_request(ws, msg: dict):
@@ -485,6 +646,9 @@ async def _handle_rpc_request(ws, msg: dict):
485
646
  handlers = {
486
647
  "health": lambda p: _rpc_health(),
487
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),
488
652
  }
489
653
  handler = handlers.get(method)
490
654
  if handler:
@@ -522,30 +686,144 @@ async def _rpc_status() -> dict:
522
686
  }
523
687
 
524
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
+
525
795
  async def _handle_shutdown():
526
- """Handle module.shutdown event — exitingack → cleanup → ready → exit."""
796
+ """Handle module.shutdown event — ackexiting → cleanup → ready → exit."""
527
797
  global _shutting_down
528
798
  print("[backup] Received shutdown request")
529
799
  _shutting_down = True
530
- # Step 0: Send module.exiting
800
+ # Step 1: Send ack (立即确认收到)
531
801
  await _publish_event(_ws_global, {
532
- "event": "module.exiting",
533
- "data": {"module_id": "backup", "action": "none"},
802
+ "event": "module.shutdown.ack",
803
+ "data": {"module_id": "backup"},
534
804
  })
535
- # Step 1: Send ack
805
+ # Step 2: Send module.exiting (开始清理)
536
806
  await _publish_event(_ws_global, {
537
- "event": "module.shutdown.ack",
538
- "data": {"module_id": "backup", "estimated_cleanup": 2},
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
+ },
539
817
  })
540
- # Step 2: Cleanup (nothing to clean up for backup)
541
- # Step 3: Send ready
818
+ # Step 3: Cleanup (nothing to clean up for backup)
819
+ # Step 4: Send ready (清理完成)
542
820
  await _publish_event(_ws_global, {
543
821
  "event": "module.shutdown.ready",
544
822
  "data": {"module_id": "backup"},
545
823
  })
546
824
  print("[backup] Shutdown ready, exiting")
547
- # Step 4: Exit
548
- sys.exit(0)
825
+ # Step 5: Exit
826
+ sys.exit(_exit_code)
549
827
 
550
828
 
551
829
  async def _test_event_loop(ws):
@@ -1,22 +1,24 @@
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
- ---
16
-
17
- # Backup(备份模块)
18
-
19
- 自动备份工程代码和数据的扩展模块。
20
-
21
- - 定时备份 — 按配置周期自动备份指定目录
22
- - 事件通知 — 通过 Event Hub 发布备份状态事件
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 发布备份状态事件