@agentunion/kite 1.0.7 → 1.2.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/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- package/package.json +1 -1
package/core/event_hub/entry.py
CHANGED
|
@@ -1,20 +1,240 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Event Hub entry point.
|
|
3
|
-
Reads token from stdin boot_info, reads launcher_ws_token
|
|
4
|
-
starts FastAPI server, outputs ws_endpoint via
|
|
5
|
-
then registers to Registry after stdio disconnect.
|
|
3
|
+
Reads token from stdin boot_info, then reads launcher_ws_token + registry_port from
|
|
4
|
+
stdin kite messages (in any order), starts FastAPI server, outputs ws_endpoint via
|
|
5
|
+
stdout, waits for Launcher to connect, then registers to Registry after stdio disconnect.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import builtins
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
11
12
|
import socket
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
15
|
+
import time
|
|
16
|
+
import traceback
|
|
17
|
+
from datetime import datetime, timezone
|
|
14
18
|
|
|
15
19
|
import uvicorn
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
# ── Module configuration ──
|
|
23
|
+
MODULE_NAME = "event_hub"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt_elapsed(t0: float) -> str:
|
|
27
|
+
"""Format elapsed time since t0: <1s → 'NNNms', >=1s → 'N.Ns', >=10s → 'NNs'."""
|
|
28
|
+
d = time.monotonic() - t0
|
|
29
|
+
if d < 1:
|
|
30
|
+
return f"{d * 1000:.0f}ms"
|
|
31
|
+
if d < 10:
|
|
32
|
+
return f"{d:.1f}s"
|
|
33
|
+
return f"{d:.0f}s"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
|
|
37
|
+
|
|
38
|
+
class _SafeWriter:
|
|
39
|
+
"""Wraps a stream to silently swallow BrokenPipeError on write/flush."""
|
|
40
|
+
def __init__(self, stream):
|
|
41
|
+
self._stream = stream
|
|
42
|
+
|
|
43
|
+
def write(self, s):
|
|
44
|
+
try:
|
|
45
|
+
self._stream.write(s)
|
|
46
|
+
except (BrokenPipeError, OSError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def flush(self):
|
|
50
|
+
try:
|
|
51
|
+
self._stream.flush()
|
|
52
|
+
except (BrokenPipeError, OSError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def __getattr__(self, name):
|
|
56
|
+
return getattr(self._stream, name)
|
|
57
|
+
|
|
58
|
+
sys.stdout = _SafeWriter(sys.stdout)
|
|
59
|
+
sys.stderr = _SafeWriter(sys.stderr)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Timestamped print + log file writer ──
|
|
63
|
+
# Independent implementation per module (no shared code dependency)
|
|
64
|
+
|
|
65
|
+
_builtin_print = builtins.print
|
|
66
|
+
_start_ts = time.monotonic()
|
|
67
|
+
_last_ts = time.monotonic()
|
|
68
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
69
|
+
_log_lock = threading.Lock()
|
|
70
|
+
_log_latest_path = None
|
|
71
|
+
_log_daily_path = None
|
|
72
|
+
_log_daily_date = ""
|
|
73
|
+
_log_dir = None
|
|
74
|
+
_crash_log_path = None
|
|
75
|
+
|
|
76
|
+
def _strip_ansi(s: str) -> str:
|
|
77
|
+
return _ANSI_RE.sub("", s)
|
|
78
|
+
|
|
79
|
+
def _resolve_daily_log_path():
|
|
80
|
+
"""Resolve daily log path based on current date."""
|
|
81
|
+
global _log_daily_path, _log_daily_date
|
|
82
|
+
if not _log_dir:
|
|
83
|
+
return
|
|
84
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
85
|
+
if today == _log_daily_date and _log_daily_path:
|
|
86
|
+
return
|
|
87
|
+
month_dir = os.path.join(_log_dir, today[:7])
|
|
88
|
+
os.makedirs(month_dir, exist_ok=True)
|
|
89
|
+
_log_daily_path = os.path.join(month_dir, f"{today}.log")
|
|
90
|
+
_log_daily_date = today
|
|
91
|
+
|
|
92
|
+
def _write_log(plain_line: str):
|
|
93
|
+
"""Write a plain-text line to both latest.log and daily log."""
|
|
94
|
+
with _log_lock:
|
|
95
|
+
if _log_latest_path:
|
|
96
|
+
try:
|
|
97
|
+
with open(_log_latest_path, "a", encoding="utf-8") as f:
|
|
98
|
+
f.write(plain_line)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
_resolve_daily_log_path()
|
|
102
|
+
if _log_daily_path:
|
|
103
|
+
try:
|
|
104
|
+
with open(_log_daily_path, "a", encoding="utf-8") as f:
|
|
105
|
+
f.write(plain_line)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
|
|
110
|
+
"""Write crash record to crashes.jsonl + daily crash archive."""
|
|
111
|
+
record = {
|
|
112
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
113
|
+
"module": MODULE_NAME,
|
|
114
|
+
"thread": thread_name or threading.current_thread().name,
|
|
115
|
+
"exception_type": exc_type.__name__ if exc_type else "Unknown",
|
|
116
|
+
"exception_message": str(exc_value),
|
|
117
|
+
"traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
|
118
|
+
"severity": severity,
|
|
119
|
+
"handled": handled,
|
|
120
|
+
"process_id": os.getpid(),
|
|
121
|
+
"platform": sys.platform,
|
|
122
|
+
"runtime_version": f"Python {sys.version.split()[0]}",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if exc_tb:
|
|
126
|
+
tb_entries = traceback.extract_tb(exc_tb)
|
|
127
|
+
if tb_entries:
|
|
128
|
+
last = tb_entries[-1]
|
|
129
|
+
record["context"] = {
|
|
130
|
+
"function": last.name,
|
|
131
|
+
"file": os.path.basename(last.filename),
|
|
132
|
+
"line": last.lineno,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
136
|
+
|
|
137
|
+
if _crash_log_path:
|
|
138
|
+
try:
|
|
139
|
+
with open(_crash_log_path, "a", encoding="utf-8") as f:
|
|
140
|
+
f.write(line)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
if _log_dir:
|
|
145
|
+
try:
|
|
146
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
147
|
+
archive_dir = os.path.join(_log_dir, "crashes", today[:7])
|
|
148
|
+
os.makedirs(archive_dir, exist_ok=True)
|
|
149
|
+
archive_path = os.path.join(archive_dir, f"{today}.jsonl")
|
|
150
|
+
with open(archive_path, "a", encoding="utf-8") as f:
|
|
151
|
+
f.write(line)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def _print_crash_summary(exc_type, exc_tb, thread_name=None):
|
|
156
|
+
"""Print crash summary to console (red highlight)."""
|
|
157
|
+
RED = "\033[91m"
|
|
158
|
+
RESET = "\033[0m"
|
|
159
|
+
|
|
160
|
+
if exc_tb:
|
|
161
|
+
tb_entries = traceback.extract_tb(exc_tb)
|
|
162
|
+
if tb_entries:
|
|
163
|
+
last = tb_entries[-1]
|
|
164
|
+
location = f"{os.path.basename(last.filename)}:{last.lineno}"
|
|
165
|
+
else:
|
|
166
|
+
location = "unknown"
|
|
167
|
+
else:
|
|
168
|
+
location = "unknown"
|
|
169
|
+
|
|
170
|
+
prefix = f"[{MODULE_NAME}]"
|
|
171
|
+
if thread_name:
|
|
172
|
+
_builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
|
|
173
|
+
f"{exc_type.__name__} in {location}{RESET}")
|
|
174
|
+
else:
|
|
175
|
+
_builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
|
|
176
|
+
if _crash_log_path:
|
|
177
|
+
_builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
|
|
178
|
+
|
|
179
|
+
def _setup_exception_hooks():
|
|
180
|
+
"""Set up global exception hooks."""
|
|
181
|
+
_orig_excepthook = sys.excepthook
|
|
182
|
+
|
|
183
|
+
def _excepthook(exc_type, exc_value, exc_tb):
|
|
184
|
+
_write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
|
|
185
|
+
_print_crash_summary(exc_type, exc_tb)
|
|
186
|
+
_orig_excepthook(exc_type, exc_value, exc_tb)
|
|
187
|
+
|
|
188
|
+
sys.excepthook = _excepthook
|
|
189
|
+
|
|
190
|
+
if hasattr(threading, "excepthook"):
|
|
191
|
+
def _thread_excepthook(args):
|
|
192
|
+
_write_crash(args.exc_type, args.exc_value, args.exc_traceback,
|
|
193
|
+
thread_name=args.thread.name if args.thread else "unknown",
|
|
194
|
+
severity="error", handled=False)
|
|
195
|
+
_print_crash_summary(args.exc_type, args.exc_traceback,
|
|
196
|
+
thread_name=args.thread.name if args.thread else None)
|
|
197
|
+
|
|
198
|
+
threading.excepthook = _thread_excepthook
|
|
199
|
+
|
|
200
|
+
def _tprint(*args, **kwargs):
|
|
201
|
+
"""Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
|
|
202
|
+
global _last_ts
|
|
203
|
+
now = time.monotonic()
|
|
204
|
+
elapsed = now - _start_ts
|
|
205
|
+
delta = now - _last_ts
|
|
206
|
+
_last_ts = now
|
|
207
|
+
|
|
208
|
+
if elapsed < 1:
|
|
209
|
+
elapsed_str = f"{elapsed * 1000:.0f}ms"
|
|
210
|
+
elif elapsed < 100:
|
|
211
|
+
elapsed_str = f"{elapsed:.1f}s"
|
|
212
|
+
else:
|
|
213
|
+
elapsed_str = f"{elapsed:.0f}s"
|
|
214
|
+
|
|
215
|
+
if delta < 0.001:
|
|
216
|
+
delta_str = ""
|
|
217
|
+
elif delta < 1:
|
|
218
|
+
delta_str = f"+{delta * 1000:.0f}ms"
|
|
219
|
+
elif delta < 100:
|
|
220
|
+
delta_str = f"+{delta:.1f}s"
|
|
221
|
+
else:
|
|
222
|
+
delta_str = f"+{delta:.0f}s"
|
|
223
|
+
|
|
224
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
225
|
+
|
|
226
|
+
_builtin_print(*args, **kwargs)
|
|
227
|
+
|
|
228
|
+
if _log_latest_path or _log_daily_path:
|
|
229
|
+
sep = kwargs.get("sep", " ")
|
|
230
|
+
end = kwargs.get("end", "\n")
|
|
231
|
+
text = sep.join(str(a) for a in args)
|
|
232
|
+
prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
|
|
233
|
+
_write_log(prefix + _strip_ansi(text) + end)
|
|
234
|
+
|
|
235
|
+
builtins.print = _tprint
|
|
236
|
+
|
|
237
|
+
# Ensure project root is on sys.path
|
|
18
238
|
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
19
239
|
_project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(_this_dir))
|
|
20
240
|
if _project_root not in sys.path:
|
|
@@ -59,29 +279,68 @@ def _bind_port(preferred: int, host: str) -> int:
|
|
|
59
279
|
return s.getsockname()[1]
|
|
60
280
|
|
|
61
281
|
|
|
62
|
-
def
|
|
63
|
-
"""Read
|
|
64
|
-
|
|
282
|
+
def _read_stdin_kite_messages(expected: set[str], timeout: float = 10) -> dict[str, dict]:
|
|
283
|
+
"""Read multiple structured kite messages from stdin until all expected types received.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
expected: set of kite message types to wait for (e.g. {"launcher_ws_token", "registry_port"})
|
|
287
|
+
timeout: total timeout in seconds
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
dict mapping kite type -> message dict. Missing types are absent from the result.
|
|
65
291
|
"""
|
|
66
|
-
|
|
292
|
+
collected: dict[str, dict] = {}
|
|
293
|
+
remaining = set(expected)
|
|
67
294
|
|
|
68
295
|
def _read():
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
296
|
+
while remaining:
|
|
297
|
+
try:
|
|
298
|
+
line = sys.stdin.readline().strip()
|
|
299
|
+
if not line:
|
|
300
|
+
return # stdin closed
|
|
72
301
|
msg = json.loads(line)
|
|
73
302
|
if isinstance(msg, dict) and "kite" in msg:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
303
|
+
kite_type = msg["kite"]
|
|
304
|
+
collected[kite_type] = msg
|
|
305
|
+
remaining.discard(kite_type)
|
|
306
|
+
except Exception:
|
|
307
|
+
return # parse error or stdin closed
|
|
77
308
|
|
|
78
309
|
t = threading.Thread(target=_read, daemon=True)
|
|
79
310
|
t.start()
|
|
80
|
-
t.join(timeout=
|
|
81
|
-
return
|
|
311
|
+
t.join(timeout=timeout)
|
|
312
|
+
return collected
|
|
82
313
|
|
|
83
314
|
|
|
84
315
|
def main():
|
|
316
|
+
# Initialize log file paths
|
|
317
|
+
global _log_dir, _log_latest_path, _crash_log_path
|
|
318
|
+
module_data = os.environ.get("KITE_MODULE_DATA")
|
|
319
|
+
if module_data:
|
|
320
|
+
_log_dir = os.path.join(module_data, "log")
|
|
321
|
+
os.makedirs(_log_dir, exist_ok=True)
|
|
322
|
+
suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
|
|
323
|
+
|
|
324
|
+
_log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
|
|
325
|
+
try:
|
|
326
|
+
with open(_log_latest_path, "w", encoding="utf-8") as f:
|
|
327
|
+
pass
|
|
328
|
+
except Exception:
|
|
329
|
+
_log_latest_path = None
|
|
330
|
+
|
|
331
|
+
_crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
|
|
332
|
+
try:
|
|
333
|
+
with open(_crash_log_path, "w", encoding="utf-8") as f:
|
|
334
|
+
pass
|
|
335
|
+
except Exception:
|
|
336
|
+
_crash_log_path = None
|
|
337
|
+
|
|
338
|
+
_resolve_daily_log_path()
|
|
339
|
+
|
|
340
|
+
_setup_exception_hooks()
|
|
341
|
+
|
|
342
|
+
_t0 = time.monotonic()
|
|
343
|
+
|
|
85
344
|
# Kite environment
|
|
86
345
|
kite_instance = os.environ.get("KITE_INSTANCE", "")
|
|
87
346
|
is_debug = os.environ.get("KITE_DEBUG") == "1"
|
|
@@ -100,24 +359,36 @@ def main():
|
|
|
100
359
|
print("[event_hub] 错误: boot_info 中缺少令牌")
|
|
101
360
|
sys.exit(1)
|
|
102
361
|
|
|
103
|
-
# Step 2:
|
|
362
|
+
# Step 2: Check env for registry_port first (fast path for restart / Phase 3.5+ scenarios)
|
|
363
|
+
registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
|
|
364
|
+
|
|
365
|
+
# Determine which stdin messages to wait for
|
|
366
|
+
stdin_expected = {"launcher_ws_token"}
|
|
367
|
+
if not registry_port:
|
|
368
|
+
stdin_expected.add("registry_port")
|
|
369
|
+
|
|
370
|
+
kite_msgs = _read_stdin_kite_messages(stdin_expected, timeout=10)
|
|
371
|
+
|
|
104
372
|
launcher_ws_token = ""
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
launcher_ws_token =
|
|
373
|
+
ws_msg = kite_msgs.get("launcher_ws_token")
|
|
374
|
+
if ws_msg:
|
|
375
|
+
launcher_ws_token = ws_msg.get("launcher_ws_token", "")
|
|
108
376
|
|
|
109
377
|
if launcher_ws_token:
|
|
110
378
|
print(f"[event_hub] 已收到启动器 WS 令牌 ({len(launcher_ws_token)} 字符)")
|
|
111
379
|
else:
|
|
112
380
|
print("[event_hub] 警告: 未收到 launcher_ws_token,启动器引导认证已禁用")
|
|
113
381
|
|
|
114
|
-
# Step 3:
|
|
115
|
-
|
|
382
|
+
# Step 3: registry_port — env already had it, or read from stdin
|
|
383
|
+
if not registry_port:
|
|
384
|
+
port_msg = kite_msgs.get("registry_port")
|
|
385
|
+
if port_msg:
|
|
386
|
+
registry_port = int(port_msg.get("registry_port", 0))
|
|
116
387
|
if not registry_port:
|
|
117
|
-
print("[event_hub] 错误:
|
|
388
|
+
print("[event_hub] 错误: 未收到 registry_port(stdin 和环境变量均无)")
|
|
118
389
|
sys.exit(1)
|
|
119
390
|
|
|
120
|
-
print(f"[event_hub] 已收到令牌 ({len(token)} 字符),Registry 端口: {registry_port}")
|
|
391
|
+
print(f"[event_hub] 已收到令牌 ({len(token)} 字符),Registry 端口: {registry_port} ({_fmt_elapsed(_t0)})")
|
|
121
392
|
|
|
122
393
|
# Step 4: Read config from own module.md
|
|
123
394
|
md_config = _read_module_md()
|
|
@@ -149,8 +420,16 @@ def main():
|
|
|
149
420
|
# Step 7: Start HTTP + WS server
|
|
150
421
|
# Launcher will connect with launcher_ws_token → Event Hub sends module.ready → stdio disconnect
|
|
151
422
|
# After stdio disconnect, Event Hub registers to Registry (done by server on_launcher_connected callback)
|
|
152
|
-
print(f"[event_hub] 启动中 {bind_host}:{port}")
|
|
153
|
-
|
|
423
|
+
print(f"[event_hub] 启动中 {bind_host}:{port} ({_fmt_elapsed(_t0)})")
|
|
424
|
+
try:
|
|
425
|
+
config = uvicorn.Config(server.app, host=bind_host, port=port, log_level="warning")
|
|
426
|
+
uvi_server = uvicorn.Server(config)
|
|
427
|
+
server._uvicorn_server = uvi_server
|
|
428
|
+
uvi_server.run()
|
|
429
|
+
except Exception as e:
|
|
430
|
+
_write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
|
|
431
|
+
_print_crash_summary(type(e), e.__traceback__)
|
|
432
|
+
sys.exit(1)
|
|
154
433
|
|
|
155
434
|
|
|
156
435
|
if __name__ == "__main__":
|
package/core/event_hub/hub.py
CHANGED
|
@@ -55,6 +55,8 @@ class EventHub:
|
|
|
55
55
|
self._cnt_dedup = 0
|
|
56
56
|
self._cnt_errors = 0
|
|
57
57
|
self._start_time = time.time()
|
|
58
|
+
# Shutdown callback: set by EventHubServer to handle own shutdown
|
|
59
|
+
self._shutdown_cb = None
|
|
58
60
|
|
|
59
61
|
# ── Connection lifecycle ──
|
|
60
62
|
|
|
@@ -186,6 +188,12 @@ class EventHub:
|
|
|
186
188
|
await self._route_event(module_id, msg)
|
|
187
189
|
await self._send_ack(ws, event_id)
|
|
188
190
|
|
|
191
|
+
# Check if this is a shutdown event targeting event_hub
|
|
192
|
+
if event_type == "module.shutdown" and self._shutdown_cb:
|
|
193
|
+
data = msg.get("data", {})
|
|
194
|
+
if data.get("module_id") == "event_hub":
|
|
195
|
+
asyncio.create_task(self._shutdown_cb(data))
|
|
196
|
+
|
|
189
197
|
# ── Routing ──
|
|
190
198
|
|
|
191
199
|
async def _route_event(self, sender_id: str, msg: dict):
|
package/core/event_hub/server.py
CHANGED
|
@@ -5,7 +5,7 @@ FastAPI app: /ws (WebSocket), /health, /stats.
|
|
|
5
5
|
|
|
6
6
|
Launcher bootstrap sequence:
|
|
7
7
|
Launcher connects with launcher_ws_token → Event Hub verifies locally →
|
|
8
|
-
|
|
8
|
+
registers to Registry → sends module.ready.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
@@ -35,7 +35,12 @@ class EventHubServer:
|
|
|
35
35
|
self._timer_task: asyncio.Task | None = None
|
|
36
36
|
self._launcher_connected = False
|
|
37
37
|
self._registered_to_registry = False
|
|
38
|
+
self._uvicorn_server = None # set by entry.py for graceful shutdown
|
|
39
|
+
self._launcher_ws: WebSocket | None = None # reference to Launcher's WS
|
|
40
|
+
self._http_client: httpx.AsyncClient | None = None # reused for Registry calls
|
|
38
41
|
self.app = self._create_app()
|
|
42
|
+
# Register shutdown callback on hub
|
|
43
|
+
self.hub._shutdown_cb = self._handle_shutdown
|
|
39
44
|
|
|
40
45
|
# ── Token verification ──
|
|
41
46
|
|
|
@@ -49,18 +54,22 @@ class EventHubServer:
|
|
|
49
54
|
if self.launcher_ws_token and token == self.launcher_ws_token:
|
|
50
55
|
return "launcher"
|
|
51
56
|
# Normal verification via Registry
|
|
57
|
+
import time as _time
|
|
58
|
+
_t0 = _time.monotonic()
|
|
52
59
|
try:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
resp = await self._http_client.post(
|
|
61
|
+
f"{self.registry_url}/verify",
|
|
62
|
+
json={"token": token},
|
|
63
|
+
headers={"Authorization": f"Bearer {self.own_token}"},
|
|
64
|
+
timeout=5,
|
|
65
|
+
)
|
|
66
|
+
_t1 = _time.monotonic()
|
|
67
|
+
if resp.status_code == 200:
|
|
68
|
+
body = resp.json()
|
|
69
|
+
if body.get("ok"):
|
|
70
|
+
mid = body.get("module_id")
|
|
71
|
+
print(f"[event_hub] _verify_token({mid}): {(_t1-_t0)*1000:.0f}ms")
|
|
72
|
+
return mid
|
|
64
73
|
except Exception as e:
|
|
65
74
|
print(f"[event_hub] Token verification failed: {e}")
|
|
66
75
|
return None
|
|
@@ -68,8 +77,13 @@ class EventHubServer:
|
|
|
68
77
|
# ── Launcher bootstrap ──
|
|
69
78
|
|
|
70
79
|
async def _on_launcher_connected(self, ws: WebSocket):
|
|
71
|
-
"""Called on first Launcher WS connect.
|
|
80
|
+
"""Called on first Launcher WS connect. Registers to Registry first, then sends module.ready.
|
|
81
|
+
This order ensures all startup-phase prints are done before Launcher calls close_stdio."""
|
|
72
82
|
self._launcher_connected = True
|
|
83
|
+
self._launcher_ws = ws
|
|
84
|
+
# Step 1: Register to Registry (may print log lines)
|
|
85
|
+
await self._register_to_registry()
|
|
86
|
+
# Step 2: Send module.ready (Launcher will close_stdio after receiving this)
|
|
73
87
|
msg = {
|
|
74
88
|
"type": "event",
|
|
75
89
|
"event_id": str(uuid.uuid4()),
|
|
@@ -79,6 +93,7 @@ class EventHubServer:
|
|
|
79
93
|
"data": {
|
|
80
94
|
"module_id": "event_hub",
|
|
81
95
|
"ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
|
|
96
|
+
"graceful_shutdown": True,
|
|
82
97
|
},
|
|
83
98
|
}
|
|
84
99
|
try:
|
|
@@ -86,8 +101,48 @@ class EventHubServer:
|
|
|
86
101
|
print("[event_hub] Sent module.ready to Launcher")
|
|
87
102
|
except Exception as e:
|
|
88
103
|
print(f"[event_hub] Failed to send module.ready: {e}")
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
|
|
105
|
+
async def _handle_shutdown(self, data: dict):
|
|
106
|
+
"""Handle module.shutdown event targeting event_hub."""
|
|
107
|
+
print("[event_hub] Received shutdown request")
|
|
108
|
+
# Send ack and ready via Launcher's WS connection
|
|
109
|
+
ws = self._launcher_ws
|
|
110
|
+
if ws:
|
|
111
|
+
try:
|
|
112
|
+
ack_msg = {
|
|
113
|
+
"type": "event",
|
|
114
|
+
"event_id": str(uuid.uuid4()),
|
|
115
|
+
"event": "module.shutdown.ack",
|
|
116
|
+
"source": "event_hub",
|
|
117
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
118
|
+
"data": {"module_id": "event_hub", "estimated_cleanup": 2},
|
|
119
|
+
}
|
|
120
|
+
await ws.send_text(json.dumps(ack_msg))
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"[event_hub] Failed to send shutdown ack: {e}")
|
|
123
|
+
# Cleanup
|
|
124
|
+
if self._timer_task:
|
|
125
|
+
self._timer_task.cancel()
|
|
126
|
+
if self._http_client:
|
|
127
|
+
await self._http_client.aclose()
|
|
128
|
+
# Send ready
|
|
129
|
+
if ws:
|
|
130
|
+
try:
|
|
131
|
+
ready_msg = {
|
|
132
|
+
"type": "event",
|
|
133
|
+
"event_id": str(uuid.uuid4()),
|
|
134
|
+
"event": "module.shutdown.ready",
|
|
135
|
+
"source": "event_hub",
|
|
136
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
137
|
+
"data": {"module_id": "event_hub"},
|
|
138
|
+
}
|
|
139
|
+
await ws.send_text(json.dumps(ready_msg))
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(f"[event_hub] Failed to send shutdown ready: {e}")
|
|
142
|
+
print("[event_hub] Shutdown ready, exiting")
|
|
143
|
+
# Trigger uvicorn exit
|
|
144
|
+
if self._uvicorn_server:
|
|
145
|
+
self._uvicorn_server.should_exit = True
|
|
91
146
|
|
|
92
147
|
async def _register_to_registry(self):
|
|
93
148
|
"""Register to Registry. Triggered after Launcher connects."""
|
|
@@ -129,6 +184,8 @@ class EventHubServer:
|
|
|
129
184
|
|
|
130
185
|
@app.on_event("startup")
|
|
131
186
|
async def _startup():
|
|
187
|
+
# Initialize HTTP client early to avoid blocking first token verification
|
|
188
|
+
server._http_client = httpx.AsyncClient()
|
|
132
189
|
server._timer_task = asyncio.create_task(server._timer_loop())
|
|
133
190
|
|
|
134
191
|
@app.on_event("shutdown")
|
|
@@ -148,7 +205,10 @@ class EventHubServer:
|
|
|
148
205
|
# causing websockets 15.x clients to get "no close frame received or sent" errors.
|
|
149
206
|
await ws.accept()
|
|
150
207
|
print(f"[event_hub] 认证失败: token={token[:8]}... hint={mid_hint}")
|
|
151
|
-
|
|
208
|
+
try:
|
|
209
|
+
await ws.close(code=4001, reason="Authentication failed")
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
152
212
|
return
|
|
153
213
|
|
|
154
214
|
await ws.accept()
|
|
@@ -168,7 +228,10 @@ class EventHubServer:
|
|
|
168
228
|
except WebSocketDisconnect:
|
|
169
229
|
pass
|
|
170
230
|
except Exception as e:
|
|
171
|
-
|
|
231
|
+
# Connection-state errors are normal during shutdown
|
|
232
|
+
err = str(e).lower()
|
|
233
|
+
if "not connected" not in err and "closed" not in err:
|
|
234
|
+
print(f"[event_hub] WebSocket error for {module_id}: {e}")
|
|
172
235
|
finally:
|
|
173
236
|
server.hub.remove_connection(module_id)
|
|
174
237
|
|