@agentunion/kite 1.3.1 → 1.3.2
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 +87 -1
- package/extensions/agents/assistant/server.py +30 -12
- package/extensions/channels/acp_channel/server.py +30 -12
- package/extensions/services/backup/entry.py +123 -65
- package/extensions/services/model_service/entry.py +123 -65
- package/extensions/services/watchdog/entry.py +171 -80
- package/extensions/services/watchdog/monitor.py +112 -6
- package/extensions/services/web/routes/routes_modules.py +249 -0
- package/extensions/services/web/routes/schemas.py +22 -0
- package/extensions/services/web/server.py +37 -14
- package/extensions/services/web/static/css/style.css +97 -0
- package/extensions/services/web/static/index.html +105 -2
- package/extensions/services/web/static/js/app.js +288 -1
- package/kernel/event_hub.py +21 -3
- package/kernel/registry_store.py +22 -5
- package/kernel/rpc_router.py +15 -5
- package/kernel/server.py +75 -5
- package/launcher/count_lines.py +34 -0
- package/launcher/entry.py +92 -14
- package/launcher/process_manager.py +12 -1
- package/package.json +1 -1
|
@@ -6,6 +6,9 @@ Launcher handles process-level crashes; Watchdog handles app-level failures
|
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
9
12
|
import time
|
|
10
13
|
from datetime import datetime, timezone
|
|
11
14
|
|
|
@@ -85,6 +88,10 @@ class HealthMonitor:
|
|
|
85
88
|
self._system_ready_event = asyncio.Event()
|
|
86
89
|
self._crash_counts: dict[str, int] = {} # module_id -> consecutive crash count
|
|
87
90
|
|
|
91
|
+
# Launcher loss tracking
|
|
92
|
+
self._launcher_offline = False
|
|
93
|
+
self._launcher_had_exiting = False # True if module.exiting was received for launcher
|
|
94
|
+
|
|
88
95
|
# ── Module discovery ──
|
|
89
96
|
|
|
90
97
|
async def discover_modules(self):
|
|
@@ -364,6 +371,28 @@ class HealthMonitor:
|
|
|
364
371
|
self._system_ready_event.set()
|
|
365
372
|
return
|
|
366
373
|
|
|
374
|
+
# module.offline — track launcher state
|
|
375
|
+
if event_type == "module.offline":
|
|
376
|
+
if module_id == "launcher":
|
|
377
|
+
print("[watchdog] Received module.offline(launcher)")
|
|
378
|
+
self._launcher_offline = True
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# module.shutdown with reason launcher_lost — decide whether to start new instance
|
|
382
|
+
if event_type == "module.shutdown":
|
|
383
|
+
reason = data.get("reason", "")
|
|
384
|
+
if reason == "launcher_lost":
|
|
385
|
+
print("[watchdog] Received module.shutdown(reason=launcher_lost)")
|
|
386
|
+
await self._handle_launcher_lost()
|
|
387
|
+
return
|
|
388
|
+
target = data.get("module_id", "")
|
|
389
|
+
if not target:
|
|
390
|
+
# Broadcast shutdown — check reason
|
|
391
|
+
if reason == "system_shutdown":
|
|
392
|
+
print(f"[watchdog] Received system_shutdown signal")
|
|
393
|
+
self._system_shutting_down = True
|
|
394
|
+
return
|
|
395
|
+
|
|
367
396
|
if not module_id or module_id == "watchdog":
|
|
368
397
|
return
|
|
369
398
|
|
|
@@ -381,17 +410,18 @@ class HealthMonitor:
|
|
|
381
410
|
action = data.get("action", "none")
|
|
382
411
|
print(f"[watchdog] Received module.exiting: {module_id}, action={action}")
|
|
383
412
|
self._exit_intents[module_id] = action
|
|
413
|
+
# Track launcher exiting intent
|
|
414
|
+
if module_id == "launcher":
|
|
415
|
+
self._launcher_had_exiting = True
|
|
384
416
|
|
|
385
417
|
elif event_type == "module.ready":
|
|
386
418
|
graceful = bool(data.get("graceful_shutdown"))
|
|
387
419
|
print(f"[watchdog] Received module.ready: {module_id}, graceful_shutdown={graceful}")
|
|
388
420
|
self._graceful_modules[module_id] = graceful
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
print(f"[watchdog] Received system_shutdown signal")
|
|
394
|
-
self._system_shutting_down = True
|
|
421
|
+
# Reset launcher loss tracking when launcher reconnects
|
|
422
|
+
if module_id == "launcher":
|
|
423
|
+
self._launcher_offline = False
|
|
424
|
+
self._launcher_had_exiting = False
|
|
395
425
|
|
|
396
426
|
async def _handle_module_stopped(self, module_id: str, data: dict):
|
|
397
427
|
"""Restart decision engine — called when module.stopped is received.
|
|
@@ -443,6 +473,82 @@ class HealthMonitor:
|
|
|
443
473
|
"message": f"{module_id} exceeded {self.MAX_RESTARTS} crash restarts",
|
|
444
474
|
})
|
|
445
475
|
|
|
476
|
+
async def _handle_launcher_lost(self):
|
|
477
|
+
"""Handle launcher_lost: decide whether to start a new Kite instance.
|
|
478
|
+
|
|
479
|
+
- If launcher sent module.exiting before going offline → normal exit, follow suit
|
|
480
|
+
- If launcher did NOT send module.exiting → crash/unexpected, start new instance
|
|
481
|
+
"""
|
|
482
|
+
if self._launcher_had_exiting:
|
|
483
|
+
print("[watchdog] Launcher had sent module.exiting before loss → normal exit, following suit")
|
|
484
|
+
sys.exit(0)
|
|
485
|
+
else:
|
|
486
|
+
print("[watchdog] Launcher lost without module.exiting → crash detected, starting new instance")
|
|
487
|
+
self._start_new_instance()
|
|
488
|
+
print("[watchdog] New instance started, exiting")
|
|
489
|
+
sys.exit(0)
|
|
490
|
+
|
|
491
|
+
def _start_new_instance(self):
|
|
492
|
+
"""Start a new Kite instance by running python main.py in the project directory."""
|
|
493
|
+
project_dir = os.environ.get("KITE_PROJECT", "")
|
|
494
|
+
if not project_dir:
|
|
495
|
+
print("[watchdog] ERROR: KITE_PROJECT not set, cannot start new instance")
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
main_py = os.path.join(project_dir, "main.py")
|
|
499
|
+
if not os.path.exists(main_py):
|
|
500
|
+
print(f"[watchdog] ERROR: {main_py} not found, cannot start new instance")
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
print(f"[watchdog] Starting new Kite instance: python {main_py}")
|
|
504
|
+
try:
|
|
505
|
+
# Start detached process with new console window
|
|
506
|
+
if sys.platform == "win32":
|
|
507
|
+
# Use 'start' command to force visible console window
|
|
508
|
+
subprocess.Popen(
|
|
509
|
+
["cmd", "/c", "start", "Kite", sys.executable, main_py],
|
|
510
|
+
cwd=project_dir,
|
|
511
|
+
shell=False,
|
|
512
|
+
)
|
|
513
|
+
elif sys.platform == "darwin":
|
|
514
|
+
# macOS: use 'open -a Terminal' to launch in new Terminal window
|
|
515
|
+
subprocess.Popen(
|
|
516
|
+
["open", "-a", "Terminal", main_py],
|
|
517
|
+
cwd=project_dir,
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
# Linux: try common terminal emulators
|
|
521
|
+
terminals = [
|
|
522
|
+
["x-terminal-emulator", "-e"], # Debian/Ubuntu default
|
|
523
|
+
["gnome-terminal", "--"], # GNOME
|
|
524
|
+
["konsole", "-e"], # KDE
|
|
525
|
+
["xterm", "-e"], # Fallback
|
|
526
|
+
]
|
|
527
|
+
launched = False
|
|
528
|
+
for term_cmd in terminals:
|
|
529
|
+
try:
|
|
530
|
+
subprocess.Popen(
|
|
531
|
+
term_cmd + [sys.executable, main_py],
|
|
532
|
+
cwd=project_dir,
|
|
533
|
+
start_new_session=True,
|
|
534
|
+
)
|
|
535
|
+
launched = True
|
|
536
|
+
break
|
|
537
|
+
except FileNotFoundError:
|
|
538
|
+
continue
|
|
539
|
+
if not launched:
|
|
540
|
+
# Fallback: headless start
|
|
541
|
+
print("[watchdog] WARNING: No terminal emulator found, starting headless")
|
|
542
|
+
subprocess.Popen(
|
|
543
|
+
[sys.executable, main_py],
|
|
544
|
+
cwd=project_dir,
|
|
545
|
+
start_new_session=True,
|
|
546
|
+
stdin=subprocess.DEVNULL,
|
|
547
|
+
)
|
|
548
|
+
print("[watchdog] New Kite instance started successfully")
|
|
549
|
+
except Exception as e:
|
|
550
|
+
print(f"[watchdog] Failed to start new instance: {e}")
|
|
551
|
+
|
|
446
552
|
async def _restart_module_by_id(self, module_id: str, reason: str = "restart"):
|
|
447
553
|
"""Restart a module via Launcher RPC by module_id."""
|
|
448
554
|
print(f"[watchdog] Requesting restart for {module_id} (reason={reason})")
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Routes for module management — scan, view, and edit module metadata + config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from fastapi import APIRouter, HTTPException
|
|
13
|
+
|
|
14
|
+
from routes.schemas import ModuleConfigUpdate, ModuleMetadataUpdate
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["modules"])
|
|
19
|
+
|
|
20
|
+
# Fields that may NOT be changed via the metadata API
|
|
21
|
+
_READONLY_FIELDS = frozenset({"name", "type", "runtime", "entry"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _get_project_root() -> Path:
|
|
29
|
+
"""Return the Kite project root directory."""
|
|
30
|
+
env = os.environ.get("KITE_PROJECT")
|
|
31
|
+
if env:
|
|
32
|
+
return Path(env)
|
|
33
|
+
# Fallback: __file__ is routes/routes_modules.py → up 5 levels to project root
|
|
34
|
+
# routes_modules.py → routes/ → web/ → services/ → extensions/ → Kite/
|
|
35
|
+
return Path(__file__).resolve().parents[4]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _scan_modules() -> list[dict[str, Any]]:
|
|
39
|
+
"""Discover all modules by scanning kernel/ (depth 0) and extensions/ (depth 2)."""
|
|
40
|
+
root = _get_project_root()
|
|
41
|
+
found: list[dict[str, Any]] = []
|
|
42
|
+
|
|
43
|
+
# 1. kernel/module.md
|
|
44
|
+
kernel_md = root / "kernel" / "module.md"
|
|
45
|
+
if kernel_md.is_file():
|
|
46
|
+
meta, _ = _parse_module_md(kernel_md)
|
|
47
|
+
if meta:
|
|
48
|
+
meta["_path"] = str(kernel_md)
|
|
49
|
+
meta["_dir"] = str(kernel_md.parent)
|
|
50
|
+
found.append(meta)
|
|
51
|
+
|
|
52
|
+
# 2. launcher/module.md
|
|
53
|
+
launcher_md = root / "launcher" / "module.md"
|
|
54
|
+
if launcher_md.is_file():
|
|
55
|
+
meta, _ = _parse_module_md(launcher_md)
|
|
56
|
+
if meta:
|
|
57
|
+
meta["_path"] = str(launcher_md)
|
|
58
|
+
meta["_dir"] = str(launcher_md.parent)
|
|
59
|
+
found.append(meta)
|
|
60
|
+
|
|
61
|
+
# 3. extensions/{category}/{name}/module.md (depth 2)
|
|
62
|
+
ext_dir = root / "extensions"
|
|
63
|
+
if ext_dir.is_dir():
|
|
64
|
+
for category in sorted(ext_dir.iterdir()):
|
|
65
|
+
if not category.is_dir() or category.name.startswith("."):
|
|
66
|
+
continue
|
|
67
|
+
for mod_dir in sorted(category.iterdir()):
|
|
68
|
+
if not mod_dir.is_dir() or mod_dir.name.startswith("."):
|
|
69
|
+
continue
|
|
70
|
+
md_path = mod_dir / "module.md"
|
|
71
|
+
if md_path.is_file():
|
|
72
|
+
meta, _ = _parse_module_md(md_path)
|
|
73
|
+
if meta:
|
|
74
|
+
meta["_path"] = str(md_path)
|
|
75
|
+
meta["_dir"] = str(mod_dir)
|
|
76
|
+
found.append(meta)
|
|
77
|
+
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_module_md(path: Path) -> tuple[dict[str, Any], str]:
|
|
85
|
+
"""Parse YAML frontmatter from a module.md file. Returns (frontmatter_dict, body_str)."""
|
|
86
|
+
try:
|
|
87
|
+
text = path.read_text(encoding="utf-8")
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
logger.warning("Failed to read %s: %s", path, exc)
|
|
90
|
+
return {}, ""
|
|
91
|
+
|
|
92
|
+
m = _FRONTMATTER_RE.match(text)
|
|
93
|
+
if not m:
|
|
94
|
+
return {}, text
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
fm = yaml.safe_load(m.group(1)) or {}
|
|
98
|
+
except yaml.YAMLError as exc:
|
|
99
|
+
logger.warning("Failed to parse frontmatter in %s: %s", path, exc)
|
|
100
|
+
return {}, text
|
|
101
|
+
|
|
102
|
+
return fm, m.group(2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_module_md(path: Path, frontmatter: dict[str, Any], body: str) -> None:
|
|
106
|
+
"""Write frontmatter + body back to module.md."""
|
|
107
|
+
fm_str = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False).rstrip()
|
|
108
|
+
content = f"---\n{fm_str}\n---\n{body}"
|
|
109
|
+
path.write_text(content, encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_module(name: str) -> dict[str, Any] | None:
|
|
113
|
+
"""Find a single module by name."""
|
|
114
|
+
for m in _scan_modules():
|
|
115
|
+
if m.get("name") == name:
|
|
116
|
+
return m
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _deep_merge(base: dict, overlay: dict) -> dict:
|
|
121
|
+
"""Recursively merge overlay into base (mutates base)."""
|
|
122
|
+
for k, v in overlay.items():
|
|
123
|
+
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
|
|
124
|
+
_deep_merge(base[k], v)
|
|
125
|
+
else:
|
|
126
|
+
base[k] = v
|
|
127
|
+
return base
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# API endpoints
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
@router.get("/modules")
|
|
135
|
+
async def list_modules():
|
|
136
|
+
"""List all discovered modules."""
|
|
137
|
+
modules = _scan_modules()
|
|
138
|
+
result = []
|
|
139
|
+
for m in modules:
|
|
140
|
+
mod_dir = Path(m["_dir"])
|
|
141
|
+
has_config = (mod_dir / "config.yaml").is_file()
|
|
142
|
+
result.append({
|
|
143
|
+
"name": m.get("name", ""),
|
|
144
|
+
"display_name": m.get("display_name", ""),
|
|
145
|
+
"type": m.get("type", ""),
|
|
146
|
+
"state": m.get("state", "enabled"),
|
|
147
|
+
"version": m.get("version", ""),
|
|
148
|
+
"runtime": m.get("runtime", ""),
|
|
149
|
+
"preferred_port": m.get("preferred_port"),
|
|
150
|
+
"monitor": m.get("monitor"),
|
|
151
|
+
"has_config": has_config,
|
|
152
|
+
})
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/modules/{name}")
|
|
157
|
+
async def get_module(name: str):
|
|
158
|
+
"""Get module details including config.yaml content (if any)."""
|
|
159
|
+
m = _find_module(name)
|
|
160
|
+
if not m:
|
|
161
|
+
raise HTTPException(404, f"Module not found: {name}")
|
|
162
|
+
|
|
163
|
+
mod_dir = Path(m["_dir"])
|
|
164
|
+
md_path = Path(m["_path"])
|
|
165
|
+
|
|
166
|
+
# Parse full frontmatter
|
|
167
|
+
frontmatter, _ = _parse_module_md(md_path)
|
|
168
|
+
|
|
169
|
+
# Read config.yaml if present
|
|
170
|
+
config = None
|
|
171
|
+
config_path = mod_dir / "config.yaml"
|
|
172
|
+
if config_path.is_file():
|
|
173
|
+
try:
|
|
174
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.warning("Failed to read config.yaml for %s: %s", name, exc)
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"name": frontmatter.get("name", name),
|
|
180
|
+
"display_name": frontmatter.get("display_name", ""),
|
|
181
|
+
"type": frontmatter.get("type", ""),
|
|
182
|
+
"state": frontmatter.get("state", "enabled"),
|
|
183
|
+
"version": frontmatter.get("version", ""),
|
|
184
|
+
"runtime": frontmatter.get("runtime", ""),
|
|
185
|
+
"entry": frontmatter.get("entry", ""),
|
|
186
|
+
"preferred_port": frontmatter.get("preferred_port"),
|
|
187
|
+
"advertise_ip": frontmatter.get("advertise_ip"),
|
|
188
|
+
"monitor": frontmatter.get("monitor"),
|
|
189
|
+
"events": frontmatter.get("events"),
|
|
190
|
+
"subscriptions": frontmatter.get("subscriptions"),
|
|
191
|
+
"depends_on": frontmatter.get("depends_on"),
|
|
192
|
+
"has_config": config is not None,
|
|
193
|
+
"config": config,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.put("/modules/{name}/metadata")
|
|
198
|
+
async def update_module_metadata(name: str, update: ModuleMetadataUpdate):
|
|
199
|
+
"""Update module.md frontmatter (whitelisted fields only)."""
|
|
200
|
+
m = _find_module(name)
|
|
201
|
+
if not m:
|
|
202
|
+
raise HTTPException(404, f"Module not found: {name}")
|
|
203
|
+
|
|
204
|
+
md_path = Path(m["_path"])
|
|
205
|
+
frontmatter, body = _parse_module_md(md_path)
|
|
206
|
+
if not frontmatter:
|
|
207
|
+
raise HTTPException(500, "Failed to parse module.md frontmatter")
|
|
208
|
+
|
|
209
|
+
# Apply only non-None fields, excluding readonly
|
|
210
|
+
changes = update.model_dump(exclude_none=True)
|
|
211
|
+
for key in list(changes.keys()):
|
|
212
|
+
if key in _READONLY_FIELDS:
|
|
213
|
+
continue
|
|
214
|
+
frontmatter[key] = changes[key]
|
|
215
|
+
|
|
216
|
+
_write_module_md(md_path, frontmatter, body)
|
|
217
|
+
return {"ok": True, "name": name}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@router.put("/modules/{name}/config")
|
|
221
|
+
async def update_module_config(name: str, update: ModuleConfigUpdate):
|
|
222
|
+
"""Deep-merge update into module's config.yaml."""
|
|
223
|
+
m = _find_module(name)
|
|
224
|
+
if not m:
|
|
225
|
+
raise HTTPException(404, f"Module not found: {name}")
|
|
226
|
+
|
|
227
|
+
mod_dir = Path(m["_dir"])
|
|
228
|
+
config_path = mod_dir / "config.yaml"
|
|
229
|
+
|
|
230
|
+
# Read existing config
|
|
231
|
+
existing: dict[str, Any] = {}
|
|
232
|
+
if config_path.is_file():
|
|
233
|
+
try:
|
|
234
|
+
existing = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
# Deep merge
|
|
239
|
+
overlay = update.model_dump(exclude_none=True)
|
|
240
|
+
# Remove pydantic internal keys
|
|
241
|
+
overlay.pop("model_config", None)
|
|
242
|
+
_deep_merge(existing, overlay)
|
|
243
|
+
|
|
244
|
+
# Write back
|
|
245
|
+
config_path.write_text(
|
|
246
|
+
yaml.dump(existing, allow_unicode=True, sort_keys=False, default_flow_style=False),
|
|
247
|
+
encoding="utf-8",
|
|
248
|
+
)
|
|
249
|
+
return {"ok": True, "name": name, "config": existing}
|
|
@@ -214,3 +214,25 @@ class ConfigUpdate(BaseModel):
|
|
|
214
214
|
"""Arbitrary config update payload — accepts any key/value pairs."""
|
|
215
215
|
|
|
216
216
|
model_config = {"extra": "allow"}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Modules
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
class ModuleMetadataUpdate(BaseModel):
|
|
224
|
+
"""可更新的 module.md frontmatter 字段."""
|
|
225
|
+
display_name: Optional[str] = None
|
|
226
|
+
version: Optional[str] = None
|
|
227
|
+
state: Optional[str] = None # enabled | manual | disabled
|
|
228
|
+
preferred_port: Optional[int] = None
|
|
229
|
+
advertise_ip: Optional[str] = None
|
|
230
|
+
monitor: Optional[bool] = None
|
|
231
|
+
events: Optional[list[str]] = None
|
|
232
|
+
subscriptions: Optional[list[str]] = None
|
|
233
|
+
depends_on: Optional[list[str]] = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ModuleConfigUpdate(BaseModel):
|
|
237
|
+
"""config.yaml 更新,接受任意 key/value."""
|
|
238
|
+
model_config = {"extra": "allow"}
|
|
@@ -30,6 +30,7 @@ from routes.routes_contacts import router as contacts_router
|
|
|
30
30
|
from routes.routes_stats import router as stats_router
|
|
31
31
|
from routes.routes_voicechat import router as voicechat_router
|
|
32
32
|
from routes.routes_devlog import router as devlog_router
|
|
33
|
+
from routes.routes_modules import router as modules_router
|
|
33
34
|
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
35
36
|
|
|
@@ -46,7 +47,6 @@ class WebServer:
|
|
|
46
47
|
self._ws_task: asyncio.Task | None = None
|
|
47
48
|
self._test_task: asyncio.Task | None = None
|
|
48
49
|
self._ws: object | None = None
|
|
49
|
-
self._ready_sent = False
|
|
50
50
|
self._shutting_down = False
|
|
51
51
|
self._uvicorn_server = None # set by entry.py for graceful shutdown
|
|
52
52
|
self._start_time = time.time()
|
|
@@ -142,6 +142,7 @@ class WebServer:
|
|
|
142
142
|
app.include_router(stats_router, prefix="/api")
|
|
143
143
|
app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
|
|
144
144
|
app.include_router(devlog_router, prefix="/api")
|
|
145
|
+
app.include_router(modules_router, prefix="/api")
|
|
145
146
|
|
|
146
147
|
# Serve frontend static files
|
|
147
148
|
static_dir = Path(__file__).parent / "static"
|
|
@@ -154,21 +155,34 @@ class WebServer:
|
|
|
154
155
|
|
|
155
156
|
async def _ws_loop(self):
|
|
156
157
|
"""Connect to Kernel, subscribe, register, and listen. Reconnect on failure."""
|
|
157
|
-
retry_delay = 0.
|
|
158
|
-
max_delay =
|
|
158
|
+
retry_delay = 0.3
|
|
159
|
+
max_delay = 5.0
|
|
160
|
+
max_retries = 10
|
|
161
|
+
attempt = 0
|
|
159
162
|
while not self._shutting_down:
|
|
160
163
|
try:
|
|
161
164
|
await self._ws_connect()
|
|
162
|
-
retry_delay = 0.
|
|
165
|
+
retry_delay = 0.3 # reset on successful connection
|
|
166
|
+
attempt = 0
|
|
163
167
|
except asyncio.CancelledError:
|
|
164
168
|
return
|
|
165
169
|
except Exception as e:
|
|
166
|
-
|
|
170
|
+
attempt += 1
|
|
171
|
+
# Auth failure — don't retry
|
|
172
|
+
if hasattr(e, 'rcvd') and e.rcvd is not None:
|
|
173
|
+
code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
|
|
174
|
+
if code in (4001, 4003):
|
|
175
|
+
print(f"[web] Kernel 认证失败 (code {code}),退出")
|
|
176
|
+
import sys; sys.exit(1)
|
|
177
|
+
if attempt >= max_retries:
|
|
178
|
+
print(f"[web] Kernel 重连失败 {max_retries} 次,退出")
|
|
179
|
+
import sys; sys.exit(1)
|
|
180
|
+
print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s ({attempt}/{max_retries})")
|
|
167
181
|
self._ws = None
|
|
168
182
|
if self._shutting_down:
|
|
169
183
|
return
|
|
170
184
|
await asyncio.sleep(retry_delay)
|
|
171
|
-
retry_delay = min(retry_delay * 2, max_delay)
|
|
185
|
+
retry_delay = min(retry_delay * 2, max_delay)
|
|
172
186
|
|
|
173
187
|
async def _ws_connect(self):
|
|
174
188
|
"""Single WebSocket session: connect, register, subscribe, receive loop."""
|
|
@@ -207,8 +221,8 @@ class WebServer:
|
|
|
207
221
|
})
|
|
208
222
|
print(f"[web] Registered to Kernel{elapsed_str}")
|
|
209
223
|
|
|
210
|
-
# Send module.ready (
|
|
211
|
-
if not self.
|
|
224
|
+
# Send module.ready (every reconnect, not just first time)
|
|
225
|
+
if not self._shutting_down:
|
|
212
226
|
await self._rpc_call(ws, "event.publish", {
|
|
213
227
|
"event_id": str(uuid.uuid4()),
|
|
214
228
|
"event": "module.ready",
|
|
@@ -217,7 +231,6 @@ class WebServer:
|
|
|
217
231
|
"graceful_shutdown": True,
|
|
218
232
|
},
|
|
219
233
|
})
|
|
220
|
-
self._ready_sent = True
|
|
221
234
|
elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
|
|
222
235
|
elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
|
|
223
236
|
print(f"[web] module.ready sent{elapsed_str}")
|
|
@@ -269,10 +282,14 @@ class WebServer:
|
|
|
269
282
|
event_type = params.get("event", "")
|
|
270
283
|
data = params.get("data", {})
|
|
271
284
|
|
|
272
|
-
# Special handling for module.shutdown
|
|
273
|
-
if event_type == "module.shutdown"
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
# Special handling for module.shutdown
|
|
286
|
+
if event_type == "module.shutdown":
|
|
287
|
+
target = data.get("module_id", "")
|
|
288
|
+
reason = data.get("reason", "")
|
|
289
|
+
# Handle both targeted shutdown (module_id == "web") and broadcast shutdown (no module_id or launcher_lost)
|
|
290
|
+
if target == "web" or not target or reason == "launcher_lost":
|
|
291
|
+
await self._handle_shutdown()
|
|
292
|
+
return
|
|
276
293
|
|
|
277
294
|
# Log other events
|
|
278
295
|
print(f"[web] Event received: {event_type}")
|
|
@@ -321,10 +338,16 @@ class WebServer:
|
|
|
321
338
|
}
|
|
322
339
|
|
|
323
340
|
async def _handle_shutdown(self):
|
|
324
|
-
"""Handle module.shutdown: ack → cleanup → ready → exit."""
|
|
341
|
+
"""Handle module.shutdown: exiting → ack → cleanup → ready → exit."""
|
|
325
342
|
print("[web] Received module.shutdown")
|
|
326
343
|
self._shutting_down = True
|
|
327
344
|
|
|
345
|
+
# Step 0: Send module.exiting
|
|
346
|
+
await self._publish_event({
|
|
347
|
+
"event": "module.exiting",
|
|
348
|
+
"data": {"module_id": "web", "action": "none"},
|
|
349
|
+
})
|
|
350
|
+
|
|
328
351
|
# Step 1: Send ack
|
|
329
352
|
await self._publish_event({
|
|
330
353
|
"event": "module.shutdown.ack",
|
|
@@ -1062,3 +1062,100 @@ html, body {
|
|
|
1062
1062
|
.toggle-switch-label input:checked + .toggle-switch::after {
|
|
1063
1063
|
transform: translateX(16px);
|
|
1064
1064
|
}
|
|
1065
|
+
|
|
1066
|
+
/* ===== MODULES PAGE ===== */
|
|
1067
|
+
.modules-grid {
|
|
1068
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
1069
|
+
gap: 16px;
|
|
1070
|
+
}
|
|
1071
|
+
.module-card {
|
|
1072
|
+
background: var(--white); border-radius: var(--radius);
|
|
1073
|
+
box-shadow: var(--shadow); padding: 16px; cursor: pointer;
|
|
1074
|
+
border: 2px solid transparent; transition: all .15s;
|
|
1075
|
+
}
|
|
1076
|
+
.module-card:hover {
|
|
1077
|
+
border-color: var(--primary); box-shadow: var(--shadow-md);
|
|
1078
|
+
}
|
|
1079
|
+
.module-card-header {
|
|
1080
|
+
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
1081
|
+
}
|
|
1082
|
+
.module-card-name {
|
|
1083
|
+
font-size: 16px; font-weight: 600; color: var(--gray-900);
|
|
1084
|
+
margin-bottom: 2px;
|
|
1085
|
+
}
|
|
1086
|
+
.module-card-display-name {
|
|
1087
|
+
font-size: 12px; color: var(--gray-500); margin-bottom: 10px;
|
|
1088
|
+
min-height: 16px;
|
|
1089
|
+
}
|
|
1090
|
+
.module-card-footer {
|
|
1091
|
+
display: flex; align-items: center; gap: 8px;
|
|
1092
|
+
min-height: 20px;
|
|
1093
|
+
}
|
|
1094
|
+
.module-version {
|
|
1095
|
+
font-size: 11px; color: var(--gray-400); font-weight: 500;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* Module type badges */
|
|
1099
|
+
.module-type-badge {
|
|
1100
|
+
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
1101
|
+
font-size: 11px; font-weight: 600; text-transform: lowercase;
|
|
1102
|
+
background: var(--gray-100); color: var(--gray-500);
|
|
1103
|
+
}
|
|
1104
|
+
.module-type-badge.type-infrastructure { background: #f3e8ff; color: #7c3aed; }
|
|
1105
|
+
.module-type-badge.type-service { background: var(--primary-light); color: var(--primary); }
|
|
1106
|
+
.module-type-badge.type-channel { background: var(--success-light); color: var(--success); }
|
|
1107
|
+
.module-type-badge.type-agent { background: #fff7ed; color: #ea580c; }
|
|
1108
|
+
.module-type-badge.type-tool { background: var(--gray-100); color: var(--gray-500); }
|
|
1109
|
+
|
|
1110
|
+
/* Module state dots */
|
|
1111
|
+
.module-state-dot {
|
|
1112
|
+
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
1113
|
+
flex-shrink: 0; background: var(--gray-300);
|
|
1114
|
+
}
|
|
1115
|
+
.module-state-dot.enabled { background: var(--success); }
|
|
1116
|
+
.module-state-dot.manual { background: var(--warning); }
|
|
1117
|
+
.module-state-dot.disabled { background: var(--gray-300); }
|
|
1118
|
+
|
|
1119
|
+
/* Config tree */
|
|
1120
|
+
.config-tree-group {
|
|
1121
|
+
margin-bottom: 4px;
|
|
1122
|
+
}
|
|
1123
|
+
.config-tree-key {
|
|
1124
|
+
font-size: 13px; font-weight: 600; color: var(--gray-700);
|
|
1125
|
+
padding: 6px 8px; cursor: pointer; border-radius: 4px;
|
|
1126
|
+
user-select: none; display: flex; align-items: center; gap: 4px;
|
|
1127
|
+
}
|
|
1128
|
+
.config-tree-key::before {
|
|
1129
|
+
content: '\25BE'; font-size: 10px; color: var(--gray-400);
|
|
1130
|
+
display: inline-block; transition: transform .15s;
|
|
1131
|
+
}
|
|
1132
|
+
.config-tree-group.collapsed .config-tree-key::before {
|
|
1133
|
+
transform: rotate(-90deg);
|
|
1134
|
+
}
|
|
1135
|
+
.config-tree-key:hover { background: var(--gray-50); }
|
|
1136
|
+
.config-tree-nested {
|
|
1137
|
+
margin-left: 12px; padding-left: 12px;
|
|
1138
|
+
border-left: 2px solid var(--gray-200);
|
|
1139
|
+
}
|
|
1140
|
+
.config-tree-group.collapsed .config-tree-nested { display: none; }
|
|
1141
|
+
.config-tree-leaf {
|
|
1142
|
+
display: flex; align-items: center; gap: 10px;
|
|
1143
|
+
padding: 4px 8px; margin-bottom: 2px;
|
|
1144
|
+
}
|
|
1145
|
+
.config-tree-label {
|
|
1146
|
+
font-size: 13px; color: var(--gray-600); min-width: 140px;
|
|
1147
|
+
font-weight: 500; flex-shrink: 0;
|
|
1148
|
+
}
|
|
1149
|
+
.config-tree-input {
|
|
1150
|
+
flex: 1; padding: 5px 10px; border: 1px solid var(--gray-300);
|
|
1151
|
+
border-radius: 6px; font-size: 13px; color: var(--gray-800);
|
|
1152
|
+
background: var(--white); transition: border-color .15s;
|
|
1153
|
+
min-width: 0;
|
|
1154
|
+
}
|
|
1155
|
+
.config-tree-input:focus {
|
|
1156
|
+
outline: none; border-color: var(--primary);
|
|
1157
|
+
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
|
|
1158
|
+
}
|
|
1159
|
+
.config-tree-checkbox {
|
|
1160
|
+
width: 16px; height: 16px; cursor: pointer;
|
|
1161
|
+
}
|