@agentunion/kite 1.0.6 → 1.0.7

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 (49) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +105 -61
  3. package/core/event_hub/module.md +0 -1
  4. package/core/event_hub/server.py +96 -28
  5. package/core/launcher/entry.py +477 -290
  6. package/core/launcher/module_scanner.py +10 -9
  7. package/core/launcher/process_manager.py +120 -96
  8. package/core/registry/entry.py +66 -30
  9. package/core/registry/server.py +47 -14
  10. package/core/registry/store.py +6 -1
  11. package/{core → extensions}/event_hub_bench/entry.py +17 -9
  12. package/{core → extensions}/event_hub_bench/module.md +2 -1
  13. package/extensions/services/watchdog/entry.py +11 -7
  14. package/extensions/services/watchdog/server.py +1 -1
  15. package/main.py +204 -4
  16. package/package.json +11 -2
  17. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  19. package/core/data_dir.py +0 -62
  20. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  22. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  23. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  24. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  25. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  26. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  27. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  28. package/core/event_hub/bench_results/.gitkeep +0 -0
  29. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  30. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  31. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  32. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  34. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  35. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  36. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  37. package/core/launcher/data/token.txt +0 -1
  38. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  39. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  40. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  41. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  42. package/core/registry/data/port.txt +0 -1
  43. package/core/registry/data/port_484.txt +0 -1
  44. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  45. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  46. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  48. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  49. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
package/cli.js CHANGED
@@ -1,70 +1,172 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Kite CLI — Node.js entry point.
4
- * Generates KITE_TOKEN, spawns the Python launcher, forwards output.
3
+ * Kite CLI — installer + launcher.
4
+ *
5
+ * First run: copies framework code to ~/.kite/versions/{version}/
6
+ * Subsequent runs: directly starts the installed version.
7
+ *
8
+ * Directory structure:
9
+ * ~/.kite/
10
+ * versions/{ver}/ — framework code (per-version, replaceable)
11
+ * modules/ — shared modules (all versions share, user-writable)
12
+ * data/ — runtime data (all versions share)
5
13
  *
6
14
  * Usage:
7
- * kite # after npm install -g / npm link
15
+ * kite # start current version
8
16
  * kite --cwd /some/path # override working directory
9
- * node cli.js # direct execution
10
- *
11
- * Environment variables passed to Python:
12
- * KITE_HOME — Kite framework root (where core/, extensions/ live)
13
- * KITE_CWD — directory where user executed the kite command
17
+ * kite --use 1.0.6 # use a specific version
14
18
  */
15
- const { spawn } = require('child_process');
19
+ const { spawn, execSync } = require('child_process');
16
20
  const crypto = require('crypto');
17
21
  const fs = require('fs');
18
22
  const path = require('path');
19
23
 
20
- // Parse CLI args
24
+ // Timestamped console.log with delta + color
25
+ const _origLog = console.log.bind(console);
26
+ const _origErr = console.error.bind(console);
27
+ let _lastTs = Date.now();
28
+ const DIM = '\x1b[90m', GREEN = '\x1b[32m', RED = '\x1b[91m', RST = '\x1b[0m';
29
+
30
+ function _prefix() {
31
+ const now = Date.now();
32
+ const delta = now - _lastTs;
33
+ _lastTs = now;
34
+ const d = new Date();
35
+ const ts = d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0');
36
+ const ds = delta < 1000 ? `+${delta}ms` : `+${(delta / 1000).toFixed(1)}s`;
37
+ const color = delta >= 5000 ? RED : delta >= 1000 ? GREEN : DIM;
38
+ return `${ts} ${color}${ds.padStart(8)}${RST} `;
39
+ }
40
+ console.log = (...args) => _origLog(_prefix(), ...args);
41
+ console.error = (...args) => _origErr(_prefix(), ...args);
42
+
43
+ // ── Paths ──
44
+ const packageDir = fs.realpathSync(__dirname);
45
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'));
46
+ const version = packageJson.version;
47
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
48
+ const kiteHome = path.join(homeDir, '.kite');
49
+ const modulesDir = path.join(kiteHome, 'modules');
50
+ const dataDir = path.join(kiteHome, 'data');
51
+
52
+ // ── Parse CLI args ──
21
53
  const args = process.argv.slice(2);
22
54
  const pythonArgs = [];
23
55
  let cwdOverride = null;
56
+ let useVersion = version;
24
57
 
25
58
  for (let i = 0; i < args.length; i++) {
26
59
  if (args[i] === '--cwd' && args[i + 1]) {
27
60
  cwdOverride = path.resolve(args[++i]);
61
+ } else if (args[i] === '--use' && args[i + 1]) {
62
+ useVersion = args[++i];
28
63
  } else {
29
64
  pythonArgs.push(args[i]);
30
65
  }
31
66
  }
32
67
 
33
- const token = crypto.randomBytes(32).toString('hex');
34
- const kiteHome = fs.realpathSync(__dirname);
68
+ const versionDir = path.join(kiteHome, 'versions', useVersion);
69
+
70
+ // ── Install: copy framework to ~/.kite/versions/{ver}/ if needed ──
71
+
72
+ function copyDirSync(src, dest) {
73
+ fs.mkdirSync(dest, { recursive: true });
74
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
75
+ const srcPath = path.join(src, entry.name);
76
+ const destPath = path.join(dest, entry.name);
77
+ if (entry.isDirectory()) {
78
+ // Skip directories that shouldn't be copied
79
+ if (['__pycache__', 'node_modules', '.git', 'data'].includes(entry.name)) continue;
80
+ copyDirSync(srcPath, destPath);
81
+ } else {
82
+ fs.copyFileSync(srcPath, destPath);
83
+ }
84
+ }
85
+ }
86
+
87
+ function install() {
88
+ console.log(`[kite] 安装版本 ${useVersion} 到 ${versionDir}`);
89
+
90
+ // Create shared directories
91
+ fs.mkdirSync(modulesDir, { recursive: true });
92
+ fs.mkdirSync(dataDir, { recursive: true });
93
+
94
+ // Copy framework code from npm package
95
+ copyDirSync(packageDir, versionDir);
96
+
97
+ // Patch launcher module.md: add shared modules path to discovery
98
+ patchLauncherDiscovery();
99
+
100
+ console.log(`[kite] 版本 ${useVersion} 已安装`);
101
+ }
102
+
103
+ function patchLauncherDiscovery() {
104
+ const mdPath = path.join(versionDir, 'core', 'launcher', 'module.md');
105
+ if (!fs.existsSync(mdPath)) return;
106
+
107
+ let content = fs.readFileSync(mdPath, 'utf-8');
108
+
109
+ // Already patched?
110
+ if (content.includes('shared_modules')) return;
111
+
112
+ // Insert shared_modules discovery entry before the closing ---
113
+ const entry = [
114
+ ' shared_modules:',
115
+ ' type: scan_dir',
116
+ ` path: "${modulesDir.replace(/\\/g, '/')}"`,
117
+ ' enabled: true',
118
+ ].join('\n');
119
+
120
+ // Insert after the last discovery entry, before ---
121
+ content = content.replace(
122
+ /(discovery:\n(?:[\s\S]*?))(---)/,
123
+ `$1${entry}\n$2`
124
+ );
125
+
126
+ fs.writeFileSync(mdPath, content, 'utf-8');
127
+ console.log(`[kite] 已补丁启动器发现路径: ${modulesDir}`);
128
+ }
129
+
130
+ // ── Main: check version, install if needed, start ──
131
+
132
+ if (!fs.existsSync(path.join(versionDir, 'main.py'))) {
133
+ install();
134
+ }
135
+
35
136
  const kiteCwd = cwdOverride || process.cwd();
36
137
 
37
- console.log(`[main] KITE_TOKEN generated (${token.length} chars)`);
38
- console.log(`[main] KITE_HOME: ${kiteHome}`);
39
- console.log(`[main] KITE_CWD: ${kiteCwd}`);
138
+ console.log(`[kite] 版本: ${useVersion}`);
139
+ console.log(`[kite] 项目: ${versionDir}`);
140
+ console.log(`[kite] 工作目录: ${kiteCwd}`);
40
141
 
41
- // Spawn Python using package mode: python -m Kite
42
- // PYTHONPATH points to Kite's parent so "import Kite" / "python -m Kite" works
43
- const kiteParent = path.dirname(kiteHome);
44
142
  const env = {
45
143
  ...process.env,
46
- KITE_HOME: kiteHome,
144
+ KITE_PROJECT: versionDir,
47
145
  KITE_CWD: kiteCwd,
48
- PYTHONPATH: kiteParent + (process.env.PYTHONPATH ? path.delimiter + process.env.PYTHONPATH : ''),
146
+ KITE_WORKSPACE: path.join(kiteHome, 'workspace'),
147
+ KITE_DATA: dataDir,
148
+ KITE_MODULES: modulesDir,
149
+ KITE_REPO: path.join(kiteHome, 'repo'),
150
+ KITE_VERSION: useVersion,
151
+ KITE_ENV: 'production',
49
152
  PYTHONUTF8: '1',
50
153
  };
51
154
 
52
155
  const python = process.platform === 'win32' ? 'python' : 'python3';
53
156
  const child = spawn(
54
157
  python,
55
- [path.join(kiteHome, 'main.py'), ...pythonArgs],
56
- { cwd: kiteHome, env, stdio: 'inherit' }
158
+ [path.join(versionDir, 'main.py'), ...pythonArgs],
159
+ { cwd: versionDir, env, stdio: 'inherit' }
57
160
  );
58
161
 
59
162
  child.on('error', err => {
60
- console.error(`[main] Failed to start Python: ${err.message}`);
61
- console.error(`[main] Make sure '${python}' is in PATH and dependencies are installed`);
163
+ console.error(`[kite] 启动 Python 失败: ${err.message}`);
164
+ console.error(`[kite] 请确认 '${python}' PATH 中且依赖已安装`);
62
165
  process.exit(1);
63
166
  });
64
167
 
65
168
  child.on('exit', code => process.exit(code ?? 0));
66
169
 
67
- // Forward signals for graceful shutdown
68
170
  for (const sig of ['SIGINT', 'SIGTERM']) {
69
171
  process.on(sig, () => child.kill(sig));
70
172
  }
@@ -1,19 +1,22 @@
1
1
  """
2
2
  Event Hub entry point.
3
- Reads boot_info from stdin, starts FastAPI server, registers to Registry.
3
+ Reads token from stdin boot_info, reads launcher_ws_token from stdin second line,
4
+ starts FastAPI server, outputs ws_endpoint via stdout, waits for Launcher to connect,
5
+ then registers to Registry after stdio disconnect.
4
6
  """
5
7
 
6
8
  import json
7
9
  import os
10
+ import re
8
11
  import socket
9
12
  import sys
13
+ import threading
10
14
 
11
- import httpx
12
15
  import uvicorn
13
16
 
14
- # Ensure project root is on sys.path
17
+ # Ensure project root is on sys.path (set by main.py or cli.js)
15
18
  _this_dir = os.path.dirname(os.path.abspath(__file__))
16
- _project_root = os.path.dirname(os.path.dirname(_this_dir))
19
+ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(_this_dir))
17
20
  if _project_root not in sys.path:
18
21
  sys.path.insert(0, _project_root)
19
22
 
@@ -21,91 +24,132 @@ from core.event_hub.hub import EventHub
21
24
  from core.event_hub.server import EventHubServer
22
25
 
23
26
 
24
- def _get_free_port() -> int:
25
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
26
- s.bind(("127.0.0.1", 0))
27
- return s.getsockname()[1]
28
-
29
-
30
- def _register_to_registry(token: str, registry_url: str, port: int, advertise_ip: str = "127.0.0.1"):
31
- """Synchronous registration to Registry at startup."""
32
- payload = {
33
- "action": "register",
34
- "module_id": "event_hub",
35
- "module_type": "infrastructure",
36
- "name": "Event Hub",
37
- "api_endpoint": f"http://{advertise_ip}:{port}",
38
- "health_endpoint": "/health",
39
- "metadata": {
40
- "ws_endpoint": f"ws://{advertise_ip}:{port}/ws",
41
- },
42
- }
43
- headers = {"Authorization": f"Bearer {token}"}
44
- resp = httpx.post(
45
- f"{registry_url}/modules",
46
- json=payload,
47
- headers=headers,
48
- timeout=5,
49
- )
50
- if resp.status_code == 200:
51
- print(f"[event_hub] Registered to Registry")
52
- else:
53
- print(f"[event_hub] WARNING: Registry returned {resp.status_code}")
54
-
55
-
56
- def _read_test_mode() -> bool:
57
- """Read test_mode from module.md frontmatter."""
27
+ def _read_module_md() -> dict:
28
+ """Read preferred_port, advertise_ip from own module.md."""
58
29
  md_path = os.path.join(_this_dir, "module.md")
30
+ result = {"preferred_port": 0, "advertise_ip": "127.0.0.1"}
59
31
  try:
60
32
  with open(md_path, encoding="utf-8") as f:
61
33
  text = f.read()
62
- import re, yaml
63
34
  m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
64
35
  if m:
65
- fm = yaml.safe_load(m.group(1)) or {}
66
- return bool(fm.get("test_mode", False))
36
+ try:
37
+ import yaml
38
+ fm = yaml.safe_load(m.group(1)) or {}
39
+ except ImportError:
40
+ fm = {}
41
+ result["preferred_port"] = int(fm.get("preferred_port", 0))
42
+ result["advertise_ip"] = fm.get("advertise_ip", "127.0.0.1")
67
43
  except Exception:
68
44
  pass
69
- return False
45
+ return result
46
+
47
+
48
+ def _bind_port(preferred: int, host: str) -> int:
49
+ """Try preferred port first, fall back to OS-assigned."""
50
+ if preferred:
51
+ try:
52
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
53
+ s.bind((host, preferred))
54
+ return preferred
55
+ except OSError:
56
+ pass
57
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
58
+ s.bind((host, 0))
59
+ return s.getsockname()[1]
60
+
61
+
62
+ def _read_stdin_kite_message() -> dict | None:
63
+ """Read a structured kite message from stdin (second line after boot_info).
64
+ Uses a short timeout thread to avoid blocking forever if Launcher doesn't send.
65
+ """
66
+ result = [None]
67
+
68
+ def _read():
69
+ try:
70
+ line = sys.stdin.readline().strip()
71
+ if line:
72
+ msg = json.loads(line)
73
+ if isinstance(msg, dict) and "kite" in msg:
74
+ result[0] = msg
75
+ except Exception:
76
+ pass
77
+
78
+ t = threading.Thread(target=_read, daemon=True)
79
+ t.start()
80
+ t.join(timeout=5)
81
+ return result[0]
70
82
 
71
83
 
72
84
  def main():
73
- # Read boot_info from stdin
85
+ # Kite environment
86
+ kite_instance = os.environ.get("KITE_INSTANCE", "")
87
+ is_debug = os.environ.get("KITE_DEBUG") == "1"
88
+
89
+ # Step 1: Read token from stdin boot_info
74
90
  token = ""
75
- registry_port = 0
76
- bind_host = "127.0.0.1"
77
- advertise_ip = "127.0.0.1"
78
91
  try:
79
92
  line = sys.stdin.readline().strip()
80
93
  if line:
81
94
  boot_info = json.loads(line)
82
95
  token = boot_info.get("token", "")
83
- registry_port = boot_info.get("registry_port", 0)
84
- bind_host = boot_info.get("bind", "127.0.0.1")
85
- advertise_ip = boot_info.get("advertise_ip", "127.0.0.1")
86
96
  except Exception:
87
97
  pass
88
98
 
89
- if not token or not registry_port:
90
- print("[event_hub] ERROR: Missing token or registry_port in boot_info")
99
+ if not token:
100
+ print("[event_hub] 错误: boot_info 中缺少令牌")
91
101
  sys.exit(1)
92
102
 
93
- print(f"[event_hub] Token received ({len(token)} chars), registry port: {registry_port}")
103
+ # Step 2: Read launcher_ws_token from stdin (second line, structured kite message)
104
+ launcher_ws_token = ""
105
+ kite_msg = _read_stdin_kite_message()
106
+ if kite_msg and kite_msg.get("kite") == "launcher_ws_token":
107
+ launcher_ws_token = kite_msg.get("launcher_ws_token", "")
94
108
 
109
+ if launcher_ws_token:
110
+ print(f"[event_hub] 已收到启动器 WS 令牌 ({len(launcher_ws_token)} 字符)")
111
+ else:
112
+ print("[event_hub] 警告: 未收到 launcher_ws_token,启动器引导认证已禁用")
113
+
114
+ # Step 3: Read registry_port from environment variable
115
+ registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
116
+ if not registry_port:
117
+ print("[event_hub] 错误: KITE_REGISTRY_PORT 未设置")
118
+ sys.exit(1)
119
+
120
+ print(f"[event_hub] 已收到令牌 ({len(token)} 字符),Registry 端口: {registry_port}")
121
+
122
+ # Step 4: Read config from own module.md
123
+ md_config = _read_module_md()
124
+ advertise_ip = md_config["advertise_ip"]
125
+ preferred_port = md_config["preferred_port"]
126
+
127
+ # Step 5: Bind port and create server
128
+ bind_host = advertise_ip
129
+ port = _bind_port(preferred_port, bind_host)
95
130
  registry_url = f"http://127.0.0.1:{registry_port}"
96
- port = _get_free_port()
97
131
 
98
- # Register to Registry
99
- _register_to_registry(token, registry_url, port, advertise_ip)
132
+ if is_debug:
133
+ print("[event_hub] 调试模式已启用 (KITE_DEBUG=1),接受所有令牌")
100
134
 
101
- # Create hub and server
102
- test_mode = _read_test_mode()
103
- if test_mode:
104
- print("[event_hub] WARNING: test_mode enabled, all tokens accepted")
105
135
  hub = EventHub()
106
- server = EventHubServer(hub, own_token=token, registry_url=registry_url, test_mode=test_mode)
136
+ server = EventHubServer(
137
+ hub,
138
+ own_token=token,
139
+ registry_url=registry_url,
140
+ launcher_ws_token=launcher_ws_token,
141
+ advertise_ip=advertise_ip,
142
+ port=port,
143
+ )
144
+
145
+ # Step 6: Output ws_endpoint via stdout (Launcher reads this)
146
+ ws_endpoint = f"ws://{advertise_ip}:{port}/ws"
147
+ print(json.dumps({"kite": "ws_endpoint", "ws_endpoint": ws_endpoint}), flush=True)
107
148
 
108
- print(f"[event_hub] Starting on {bind_host}:{port}")
149
+ # Step 7: Start HTTP + WS server
150
+ # Launcher will connect with launcher_ws_token → Event Hub sends module.ready → stdio disconnect
151
+ # After stdio disconnect, Event Hub registers to Registry (done by server on_launcher_connected callback)
152
+ print(f"[event_hub] 启动中 {bind_host}:{port}")
109
153
  uvicorn.run(server.app, host=bind_host, port=port, log_level="warning")
110
154
 
111
155
 
@@ -8,7 +8,6 @@ runtime: python
8
8
  entry: entry.py
9
9
  events: []
10
10
  subscriptions: []
11
- test_mode: true
12
11
  ---
13
12
 
14
13
  # Event Hub
@@ -2,9 +2,17 @@
2
2
  Event Hub HTTP + WebSocket server.
3
3
  FastAPI app: /ws (WebSocket), /health, /stats.
4
4
  30s timer for heartbeat renewal + dedup cleanup.
5
+
6
+ Launcher bootstrap sequence:
7
+ Launcher connects with launcher_ws_token → Event Hub verifies locally →
8
+ sends module.ready → registers to Registry.
5
9
  """
6
10
 
7
11
  import asyncio
12
+ import json
13
+ import os
14
+ import uuid
15
+ from datetime import datetime, timezone
8
16
 
9
17
  import httpx
10
18
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -14,20 +22,33 @@ from .hub import EventHub
14
22
 
15
23
  class EventHubServer:
16
24
 
17
- def __init__(self, hub: EventHub, own_token: str, registry_url: str, test_mode: bool = False):
25
+ def __init__(self, hub: EventHub, own_token: str, registry_url: str,
26
+ launcher_ws_token: str = "",
27
+ advertise_ip: str = "127.0.0.1", port: int = 0):
18
28
  self.hub = hub
19
29
  self.own_token = own_token
20
30
  self.registry_url = registry_url
21
- self.test_mode = test_mode
31
+ self.is_debug = os.environ.get("KITE_DEBUG") == "1"
32
+ self.launcher_ws_token = launcher_ws_token
33
+ self.advertise_ip = advertise_ip
34
+ self.port = port
22
35
  self._timer_task: asyncio.Task | None = None
36
+ self._launcher_connected = False
37
+ self._registered_to_registry = False
23
38
  self.app = self._create_app()
24
39
 
25
- # ── Token verification via Registry ──
40
+ # ── Token verification ──
26
41
 
27
42
  async def _verify_token(self, token: str, module_id_hint: str = "") -> str | None:
28
- """Call Registry POST /verify to validate a module token. Returns module_id or None."""
29
- if self.test_mode:
30
- return module_id_hint or token[:32] or "test_module"
43
+ """Verify a token. Launcher's launcher_ws_token is verified locally.
44
+ Other tokens are verified via Registry POST /verify.
45
+ In debug mode (KITE_DEBUG=1), any non-empty token is accepted."""
46
+ if self.is_debug and token:
47
+ return module_id_hint or "debug"
48
+ # Local verification for Launcher bootstrap (before Registry registration)
49
+ if self.launcher_ws_token and token == self.launcher_ws_token:
50
+ return "launcher"
51
+ # Normal verification via Registry
31
52
  try:
32
53
  async with httpx.AsyncClient() as client:
33
54
  resp = await client.post(
@@ -44,6 +65,62 @@ class EventHubServer:
44
65
  print(f"[event_hub] Token verification failed: {e}")
45
66
  return None
46
67
 
68
+ # ── Launcher bootstrap ──
69
+
70
+ async def _on_launcher_connected(self, ws: WebSocket):
71
+ """Called on first Launcher WS connect. Sends module.ready, then registers to Registry."""
72
+ self._launcher_connected = True
73
+ msg = {
74
+ "type": "event",
75
+ "event_id": str(uuid.uuid4()),
76
+ "event": "module.ready",
77
+ "source": "event_hub",
78
+ "timestamp": datetime.now(timezone.utc).isoformat(),
79
+ "data": {
80
+ "module_id": "event_hub",
81
+ "ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
82
+ },
83
+ }
84
+ try:
85
+ await ws.send_text(json.dumps(msg))
86
+ print("[event_hub] Sent module.ready to Launcher")
87
+ except Exception as e:
88
+ print(f"[event_hub] Failed to send module.ready: {e}")
89
+ # Register to Registry in background
90
+ asyncio.create_task(self._register_to_registry())
91
+
92
+ async def _register_to_registry(self):
93
+ """Register to Registry. Triggered after Launcher connects."""
94
+ if self._registered_to_registry:
95
+ return
96
+ payload = {
97
+ "action": "register",
98
+ "module_id": "event_hub",
99
+ "module_type": "infrastructure",
100
+ "name": "Event Hub",
101
+ "api_endpoint": f"http://{self.advertise_ip}:{self.port}",
102
+ "health_endpoint": "/health",
103
+ "metadata": {
104
+ "ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
105
+ },
106
+ }
107
+ headers = {"Authorization": f"Bearer {self.own_token}"}
108
+ try:
109
+ async with httpx.AsyncClient() as client:
110
+ resp = await client.post(
111
+ f"{self.registry_url}/modules",
112
+ json=payload,
113
+ headers=headers,
114
+ timeout=5,
115
+ )
116
+ if resp.status_code == 200:
117
+ self._registered_to_registry = True
118
+ print("[event_hub] Registered to Registry")
119
+ else:
120
+ print(f"[event_hub] WARNING: Registry returned {resp.status_code}")
121
+ except Exception as e:
122
+ print(f"[event_hub] WARNING: Failed to register to Registry: {e}")
123
+
47
124
  # ── App factory ──
48
125
 
49
126
  def _create_app(self) -> FastAPI:
@@ -53,14 +130,11 @@ class EventHubServer:
53
130
  @app.on_event("startup")
54
131
  async def _startup():
55
132
  server._timer_task = asyncio.create_task(server._timer_loop())
56
- server._test_task = asyncio.create_task(server._test_event_loop())
57
133
 
58
134
  @app.on_event("shutdown")
59
135
  async def _shutdown():
60
136
  if server._timer_task:
61
137
  server._timer_task.cancel()
62
- if hasattr(server, '_test_task') and server._test_task:
63
- server._test_task.cancel()
64
138
 
65
139
  # ── WebSocket endpoint ──
66
140
 
@@ -70,12 +144,23 @@ class EventHubServer:
70
144
  mid_hint = ws.query_params.get("id", "")
71
145
  module_id = await server._verify_token(token, mid_hint)
72
146
  if not module_id:
147
+ # Must accept before close — Starlette drops TCP without close frame otherwise,
148
+ # causing websockets 15.x clients to get "no close frame received or sent" errors.
149
+ await ws.accept()
150
+ print(f"[event_hub] 认证失败: token={token[:8]}... hint={mid_hint}")
73
151
  await ws.close(code=4001, reason="Authentication failed")
74
152
  return
75
153
 
76
154
  await ws.accept()
77
155
  server.hub.add_connection(module_id, ws)
78
156
 
157
+ # Launcher bootstrap: first connection triggers module.ready + Registry registration
158
+ if (module_id == "launcher"
159
+ and not server._launcher_connected
160
+ and server.launcher_ws_token
161
+ and token == server.launcher_ws_token):
162
+ await server._on_launcher_connected(ws)
163
+
79
164
  try:
80
165
  while True:
81
166
  raw = await ws.receive_text()
@@ -104,10 +189,9 @@ class EventHubServer:
104
189
  async def _timer_loop(self):
105
190
  while True:
106
191
  await asyncio.sleep(30)
107
- # Dedup cleanup (offload to thread to avoid blocking event loop)
108
192
  await asyncio.get_event_loop().run_in_executor(None, self.hub.dedup.cleanup)
109
- # Heartbeat to Registry
110
- await self._heartbeat()
193
+ if self._registered_to_registry:
194
+ await self._heartbeat()
111
195
 
112
196
  async def _heartbeat(self):
113
197
  try:
@@ -120,19 +204,3 @@ class EventHubServer:
120
204
  )
121
205
  except Exception:
122
206
  pass
123
-
124
- async def _test_event_loop(self):
125
- """Publish a test event every 5 seconds via internal routing."""
126
- import uuid
127
- from datetime import datetime, timezone
128
- while True:
129
- await asyncio.sleep(5)
130
- msg = {
131
- "type": "event",
132
- "event_id": str(uuid.uuid4()),
133
- "event": "event_hub.test",
134
- "source": "event_hub",
135
- "timestamp": datetime.now(timezone.utc).isoformat(),
136
- "data": {"message": "heartbeat from event_hub"},
137
- }
138
- await self.hub._route_event("event_hub", msg)