openclaw-agent-dashboard 1.0.39 → 1.0.41
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/dashboard/api/agent_config_api.py +28 -7
- package/dashboard/api/agents.py +48 -10
- package/dashboard/api/agents_config.py +5 -1
- package/dashboard/api/chains.py +25 -5
- package/dashboard/api/collaboration.py +10 -9
- package/dashboard/api/debug_paths.py +5 -1
- package/dashboard/api/error_analysis.py +29 -11
- package/dashboard/api/errors.py +37 -11
- package/dashboard/api/fortify_routes.py +108 -0
- package/dashboard/api/input_safety.py +60 -0
- package/dashboard/api/performance.py +73 -53
- package/dashboard/api/subagents.py +95 -99
- package/dashboard/api/timeline.py +24 -3
- package/dashboard/api/version.py +2 -0
- package/dashboard/api/websocket.py +9 -7
- package/dashboard/core/__init__.py +1 -0
- package/dashboard/core/config_fortify.py +125 -0
- package/dashboard/core/error_handler.py +488 -0
- package/dashboard/core/fallback_manager.py +81 -0
- package/dashboard/core/logging_config.py +217 -0
- package/dashboard/core/safe_api_error.py +76 -0
- package/dashboard/core/schemas/__init__.py +16 -0
- package/dashboard/core/schemas/base.py +43 -0
- package/dashboard/core/schemas/session_schema.py +40 -0
- package/dashboard/core/schemas/subagent_schema.py +23 -0
- package/dashboard/data/agent_config_manager.py +6 -4
- package/dashboard/data/chain_reader.py +16 -12
- package/dashboard/data/error_analyzer.py +15 -11
- package/dashboard/data/session_reader.py +268 -46
- package/dashboard/data/subagent_reader.py +74 -49
- package/dashboard/data/timeline_reader.py +35 -49
- package/dashboard/main.py +24 -2
- package/dashboard/mechanism_reader.py +4 -5
- package/dashboard/mechanisms.py +2 -2
- package/dashboard/pytest.ini +3 -0
- package/dashboard/requirements.txt +5 -0
- package/dashboard/status/cache_fp_probe.py +40 -0
- package/dashboard/status/status_cache.py +199 -72
- package/dashboard/status/status_calculator.py +50 -30
- package/dashboard/tests/conftest.py +87 -0
- package/dashboard/tests/test_api_contracts.py +372 -0
- package/dashboard/tests/test_bench_fortify.py +176 -0
- package/dashboard/tests/test_fortify.py +952 -0
- package/dashboard/utils/__init__.py +1 -0
- package/dashboard/utils/data_repair.py +210 -0
- package/dashboard/watchers/file_watcher.py +380 -77
- package/frontend-dist/assets/{index-cYIOn3Wq.css → index-BIZ2xHfw.css} +1 -1
- package/frontend-dist/assets/{index-DyRXGevD.js → index-Cnr0b02R.js} +1 -1
- package/frontend-dist/index.html +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dashboard/agents.py +0 -74
- package/dashboard/collaboration.py +0 -407
- package/dashboard/errors.py +0 -63
- package/dashboard/performance.py +0 -474
- package/dashboard/session_reader.py +0 -240
- package/dashboard/status_calculator.py +0 -121
- package/dashboard/subagent_reader.py +0 -232
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REQ_003-SPEC-04:按错误类型注册集中降级策略;供状态计算、列表聚合等路径调用。
|
|
3
|
+
|
|
4
|
+
REQ_003-AC-003:网络/IO 等错误在重试仍失败或未覆盖时,可读 StatusCache 中的最近状态(见 status_cache.get_stale_fallback)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
|
|
11
|
+
_Handler = Callable[..., Any]
|
|
12
|
+
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
_handlers: Dict[str, _Handler] = {}
|
|
15
|
+
_defaults_registered = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_fallback(error_category: str, handler: _Handler) -> None:
|
|
19
|
+
"""注册某 classify_exception 类别对应的降级函数;handler 签名为 (agent_id=None, **kwargs) -> Any。"""
|
|
20
|
+
with _lock:
|
|
21
|
+
_handlers[error_category] = handler
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_fallback(error_category: str, *, agent_id: Optional[str] = None, **kwargs: Any) -> Any:
|
|
25
|
+
"""按类别执行已注册降级;无匹配则返回 None。"""
|
|
26
|
+
_ensure_default_fallbacks()
|
|
27
|
+
with _lock:
|
|
28
|
+
h = _handlers.get(error_category)
|
|
29
|
+
if h is None:
|
|
30
|
+
return None
|
|
31
|
+
# NFR-R-005: Record fallback attempt (success if returns non-None)
|
|
32
|
+
try:
|
|
33
|
+
result = h(agent_id=agent_id, **kwargs)
|
|
34
|
+
from core.error_handler import record_fallback_attempt
|
|
35
|
+
|
|
36
|
+
record_fallback_attempt(success=result is not None)
|
|
37
|
+
return result
|
|
38
|
+
except Exception:
|
|
39
|
+
from core.error_handler import record_fallback_attempt
|
|
40
|
+
|
|
41
|
+
record_fallback_attempt(success=False)
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _stale_agent_status_handler(agent_id: Optional[str] = None, **_: Any) -> Optional[str]:
|
|
46
|
+
if not agent_id:
|
|
47
|
+
return None
|
|
48
|
+
from core.config_fortify import get_fortify_config
|
|
49
|
+
|
|
50
|
+
if not get_fortify_config().fallback_cache_on_io:
|
|
51
|
+
return None
|
|
52
|
+
from status.status_cache import get_cache
|
|
53
|
+
|
|
54
|
+
row = get_cache().get_stale_fallback(agent_id)
|
|
55
|
+
if not row:
|
|
56
|
+
return None
|
|
57
|
+
s = row.get("status")
|
|
58
|
+
if s in ("idle", "working", "down"):
|
|
59
|
+
return str(s)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ensure_default_fallbacks() -> None:
|
|
64
|
+
global _defaults_registered
|
|
65
|
+
if _defaults_registered:
|
|
66
|
+
return
|
|
67
|
+
with _lock:
|
|
68
|
+
if _defaults_registered:
|
|
69
|
+
return
|
|
70
|
+
for cat in ("network", "io-error", "timeout", "permission-error"):
|
|
71
|
+
if cat not in _handlers:
|
|
72
|
+
_handlers[cat] = _stale_agent_status_handler
|
|
73
|
+
_defaults_registered = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def reset_fallback_handlers_for_tests() -> None:
|
|
77
|
+
"""单测隔离:清空注册表并允许重新挂载默认处理器。"""
|
|
78
|
+
global _handlers, _defaults_registered
|
|
79
|
+
with _lock:
|
|
80
|
+
_handlers.clear()
|
|
81
|
+
_defaults_registered = False
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NFR-S-003: Logging storage security configuration.
|
|
3
|
+
|
|
4
|
+
Provides secure logging setup with:
|
|
5
|
+
- File rotation (size-based)
|
|
6
|
+
- Compression of rotated files
|
|
7
|
+
- Automatic cleanup of old logs based on retention policy
|
|
8
|
+
- File permission hardening
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from core.logging_config import setup_secure_logging
|
|
12
|
+
setup_secure_logging()
|
|
13
|
+
|
|
14
|
+
Configuration via environment variables:
|
|
15
|
+
OPENCLAW_LOG_RETENTION_DAYS: Days to retain log files (default: 30)
|
|
16
|
+
OPENCLAW_LOG_MAX_SIZE_MB: Max size per log file in MB (default: 100)
|
|
17
|
+
OPENCLAW_LOG_BACKUP_COUNT: Number of backup files to keep (default: 5)
|
|
18
|
+
OPENCLAW_LOG_FILE_PATH: Custom log file path (optional)
|
|
19
|
+
OPENCLAW_LOG_COMPRESSION: Compress rotated logs (default: true)
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import logging.handlers
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from core.config_fortify import get_fortify_config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_log_file_path() -> Optional[Path]:
|
|
34
|
+
"""Determine the log file path based on configuration."""
|
|
35
|
+
cfg = get_fortify_config()
|
|
36
|
+
if cfg.log_file_path:
|
|
37
|
+
return Path(cfg.log_file_path)
|
|
38
|
+
|
|
39
|
+
# Default path: logs/openclaw.log in project root
|
|
40
|
+
project_root = Path(__file__).parent.parent.parent
|
|
41
|
+
log_dir = project_root / "logs"
|
|
42
|
+
return log_dir / "openclaw.log"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_log_directory(log_path: Path) -> None:
|
|
46
|
+
"""Ensure log directory exists with proper permissions."""
|
|
47
|
+
log_dir = log_path.parent
|
|
48
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# Set directory permissions to 0o750 (owner rwx, group r-x, others none)
|
|
51
|
+
# Note: This may fail on Windows or if running as non-owner
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(log_dir, 0o750)
|
|
54
|
+
except (OSError, PermissionError):
|
|
55
|
+
pass # Skip on platforms that don't support chmod
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def setup_secure_logging() -> None:
|
|
59
|
+
"""
|
|
60
|
+
Configure secure logging with rotation, compression, and retention.
|
|
61
|
+
|
|
62
|
+
This sets up handlers for all openclaw.* loggers:
|
|
63
|
+
- Console handler for development
|
|
64
|
+
- Rotating file handler with compression for production
|
|
65
|
+
"""
|
|
66
|
+
cfg = get_fortify_config()
|
|
67
|
+
log_path = get_log_file_path()
|
|
68
|
+
|
|
69
|
+
if log_path is None:
|
|
70
|
+
# No file logging, just console
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
ensure_log_directory(log_path)
|
|
74
|
+
|
|
75
|
+
# Determine which loggers to configure
|
|
76
|
+
logger_names = ["openclaw", "openclaw.fortify", "openclaw.fortify.watcher",
|
|
77
|
+
"openclaw.fortify.audit", "openclaw.fortify.cache_probe"]
|
|
78
|
+
|
|
79
|
+
# Create rotating file handler
|
|
80
|
+
max_bytes = cfg.log_max_size_mb * 1024 * 1024
|
|
81
|
+
backup_count = cfg.log_backup_count
|
|
82
|
+
|
|
83
|
+
# Base rotating handler
|
|
84
|
+
if cfg.log_compression:
|
|
85
|
+
# Use custom rotating handler with gzip compression
|
|
86
|
+
handler: logging.Handler = _CompressedRotatingFileHandler(
|
|
87
|
+
filename=str(log_path),
|
|
88
|
+
maxBytes=max_bytes,
|
|
89
|
+
backupCount=backup_count,
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
94
|
+
filename=str(log_path),
|
|
95
|
+
maxBytes=max_bytes,
|
|
96
|
+
backupCount=backup_count,
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Set file permissions (owner read/write only)
|
|
101
|
+
try:
|
|
102
|
+
os.chmod(log_path, 0o600)
|
|
103
|
+
except (OSError, PermissionError):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
formatter = logging.Formatter(
|
|
107
|
+
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
108
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
109
|
+
)
|
|
110
|
+
handler.setFormatter(formatter)
|
|
111
|
+
|
|
112
|
+
# Apply to all relevant loggers
|
|
113
|
+
for logger_name in logger_names:
|
|
114
|
+
logger = logging.getLogger(logger_name)
|
|
115
|
+
# Avoid duplicate handlers
|
|
116
|
+
if not any(isinstance(h, (logging.handlers.RotatingFileHandler, _CompressedRotatingFileHandler))
|
|
117
|
+
for h in logger.handlers):
|
|
118
|
+
logger.addHandler(handler)
|
|
119
|
+
|
|
120
|
+
# Set levels based on config
|
|
121
|
+
level = getattr(logging, cfg.error_log_level, logging.INFO)
|
|
122
|
+
for logger_name in logger_names:
|
|
123
|
+
logging.getLogger(logger_name).setLevel(level)
|
|
124
|
+
|
|
125
|
+
# Schedule cleanup of old logs (best-effort)
|
|
126
|
+
_schedule_log_cleanup(log_path, cfg.log_retention_days)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _CompressedRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
|
130
|
+
"""
|
|
131
|
+
Rotating file handler that compresses old log files using gzip.
|
|
132
|
+
|
|
133
|
+
Rotated files are renamed to <filename>.1.gz, <filename>.2.gz, etc.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, filename: str, maxBytes: int = 0, backupCount: int = 0,
|
|
137
|
+
encoding: str = "utf-8", compress: bool = True):
|
|
138
|
+
super().__init__(filename, maxBytes=maxBytes, backupCount=backupCount, encoding=encoding)
|
|
139
|
+
self._compress = compress
|
|
140
|
+
|
|
141
|
+
def rotate(self, source: str, dest: str) -> None:
|
|
142
|
+
"""Compress the rotated file."""
|
|
143
|
+
super().rotate(source, dest)
|
|
144
|
+
|
|
145
|
+
if self._compress and os.path.exists(dest):
|
|
146
|
+
try:
|
|
147
|
+
import gzip
|
|
148
|
+
with open(dest, "rb") as f_in:
|
|
149
|
+
with gzip.open(dest + ".gz", "wb", compresslevel=6) as f_out:
|
|
150
|
+
f_out.writelines(f_in)
|
|
151
|
+
os.remove(dest)
|
|
152
|
+
except Exception:
|
|
153
|
+
# Compression failed, keep uncompressed file
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def shouldRollover(self, record: logging.LogRecord) -> int:
|
|
157
|
+
"""Check if rollover should occur."""
|
|
158
|
+
if self.stream is None:
|
|
159
|
+
self.stream = self._open()
|
|
160
|
+
|
|
161
|
+
if self.maxBytes > 0:
|
|
162
|
+
msg = "%s\n" % self.format(record)
|
|
163
|
+
if self.stream.tell() + len(msg) >= self.maxBytes:
|
|
164
|
+
return 1
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _schedule_log_cleanup(log_path: Path, retention_days: int) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Schedule cleanup of log files older than retention period.
|
|
171
|
+
|
|
172
|
+
This is a best-effort cleanup that runs on startup.
|
|
173
|
+
For production, use an external cron job or logrotate.
|
|
174
|
+
"""
|
|
175
|
+
import time
|
|
176
|
+
|
|
177
|
+
def _cleanup():
|
|
178
|
+
try:
|
|
179
|
+
cutoff = time.time() - (retention_days * 86400)
|
|
180
|
+
log_dir = log_path.parent
|
|
181
|
+
|
|
182
|
+
for pattern in ["*.log*", "*.gz"]:
|
|
183
|
+
for file_path in log_dir.glob(pattern):
|
|
184
|
+
if file_path.is_file() and file_path.stat().st_mtime < cutoff:
|
|
185
|
+
try:
|
|
186
|
+
file_path.unlink()
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
except Exception:
|
|
190
|
+
pass # Best-effort cleanup
|
|
191
|
+
|
|
192
|
+
# Run cleanup in background thread
|
|
193
|
+
import threading
|
|
194
|
+
t = threading.Thread(target=_cleanup, daemon=True)
|
|
195
|
+
t.start()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_logging_config_summary() -> dict:
|
|
199
|
+
"""Get a summary of the logging configuration for diagnostics."""
|
|
200
|
+
cfg = get_fortify_config()
|
|
201
|
+
log_path = get_log_file_path()
|
|
202
|
+
|
|
203
|
+
summary = {
|
|
204
|
+
"log_retention_days": cfg.log_retention_days,
|
|
205
|
+
"log_max_size_mb": cfg.log_max_size_mb,
|
|
206
|
+
"log_backup_count": cfg.log_backup_count,
|
|
207
|
+
"log_file_path": str(log_path) if log_path else None,
|
|
208
|
+
"log_compression": cfg.log_compression,
|
|
209
|
+
"log_directory_exists": log_path.parent.exists() if log_path else False,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if log_path and log_path.exists():
|
|
213
|
+
stat = log_path.stat()
|
|
214
|
+
summary["current_log_size_bytes"] = stat.st_size
|
|
215
|
+
summary["current_log_size_mb"] = round(stat.st_size / (1024 * 1024), 2)
|
|
216
|
+
|
|
217
|
+
return summary
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
面向浏览器/API 客户端的错误文案脱敏(NFR-S-001)。
|
|
3
|
+
服务端日志仍由 record_error 记录完整信息(默认不截断)。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sanitize_client_error_text(raw: str, max_len: int = 1200) -> str:
|
|
12
|
+
"""去除常见密钥/路径/邮箱形态,压缩长度;含 Traceback 时整段替换。"""
|
|
13
|
+
if not raw:
|
|
14
|
+
return "internal error"
|
|
15
|
+
s = raw.replace("\r", " ").replace("\n", " ")
|
|
16
|
+
if len(s) > max_len * 2:
|
|
17
|
+
s = s[: max_len * 2]
|
|
18
|
+
if "Traceback (most recent call last)" in raw or '\n File "' in raw:
|
|
19
|
+
return "Internal error (details redacted; see server logs)"
|
|
20
|
+
|
|
21
|
+
s = re.sub(r"\bsk-[a-zA-Z0-9]{12,}\b", "sk-[REDACTED]", s, flags=re.I)
|
|
22
|
+
s = re.sub(r"\bxox[baprs]-[a-zA-Z0-9-]{10,}\b", "[slack-token]", s)
|
|
23
|
+
s = re.sub(r"Bearer\s+[a-zA-Z0-9._=+\/-]{12,}", "Bearer [REDACTED]", s, flags=re.I)
|
|
24
|
+
s = re.sub(
|
|
25
|
+
r"\bAKIA[0-9A-Z]{16}\b",
|
|
26
|
+
"AKIA[REDACTED]",
|
|
27
|
+
s,
|
|
28
|
+
)
|
|
29
|
+
s = re.sub(r"(?i)password\s*[=:]\s*[^\s,}\"]{2,}", "password=[REDACTED]", s)
|
|
30
|
+
s = re.sub(
|
|
31
|
+
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
|
|
32
|
+
"[email]",
|
|
33
|
+
s,
|
|
34
|
+
)
|
|
35
|
+
s = re.sub(r"(?:/home/|/Users/)[^\s:]{1,80}/[^\s:]{0,200}", "[path]", s)
|
|
36
|
+
s = re.sub(r"[A-Za-z]:\\(?:[^\\\s]+\\){0,8}[^\s\\]{0,120}", "[path]", s)
|
|
37
|
+
s = re.sub(r"/[^\s:]{0,16}\.openclaw(?:/[^\s:]{0,160})?", "[openclaw-path]", s)
|
|
38
|
+
|
|
39
|
+
if len(s) > max_len:
|
|
40
|
+
s = s[:max_len] + "…"
|
|
41
|
+
return s
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def safe_client_string(message: str) -> str:
|
|
45
|
+
"""JSON 响应体中的 error 等字符串字段脱敏。"""
|
|
46
|
+
from core.config_fortify import get_fortify_config
|
|
47
|
+
|
|
48
|
+
raw = message or ""
|
|
49
|
+
if not get_fortify_config().sanitize_api_errors:
|
|
50
|
+
return raw[:4000]
|
|
51
|
+
return sanitize_client_error_text(raw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def safe_api_error_detail(exc: BaseException) -> str:
|
|
55
|
+
"""HTTP 500 等返回给客户端的 detail 字符串。"""
|
|
56
|
+
from core.config_fortify import get_fortify_config
|
|
57
|
+
|
|
58
|
+
raw = str(exc).strip() or type(exc).__name__
|
|
59
|
+
if not get_fortify_config().sanitize_api_errors:
|
|
60
|
+
return raw[:4000]
|
|
61
|
+
return sanitize_client_error_text(raw)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact_framework_stats_for_client(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
65
|
+
"""为 /api/errors/stats 等接口脱敏 last_error.detail。"""
|
|
66
|
+
from core.config_fortify import get_fortify_config
|
|
67
|
+
|
|
68
|
+
if not get_fortify_config().sanitize_api_errors:
|
|
69
|
+
return data
|
|
70
|
+
out = dict(data)
|
|
71
|
+
le = out.get("last_error")
|
|
72
|
+
if isinstance(le, dict) and le.get("detail"):
|
|
73
|
+
le = dict(le)
|
|
74
|
+
le["detail"] = sanitize_client_error_text(str(le["detail"]))
|
|
75
|
+
out["last_error"] = le
|
|
76
|
+
return out
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from core.schemas.base import SchemaValidator, ValidationResult
|
|
2
|
+
from core.schemas.session_schema import (
|
|
3
|
+
session_envelope_schema,
|
|
4
|
+
session_message_schema,
|
|
5
|
+
sessions_index_schema,
|
|
6
|
+
)
|
|
7
|
+
from core.schemas.subagent_schema import subagent_runs_root_schema
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SchemaValidator",
|
|
11
|
+
"ValidationResult",
|
|
12
|
+
"session_envelope_schema",
|
|
13
|
+
"session_message_schema",
|
|
14
|
+
"sessions_index_schema",
|
|
15
|
+
"subagent_runs_root_schema",
|
|
16
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""JSON Schema validation wrapper (jsonschema)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import jsonschema
|
|
8
|
+
from jsonschema import Draft202012Validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ValidationResult:
|
|
13
|
+
is_valid: bool
|
|
14
|
+
errors: List[str] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def error_message(self) -> str:
|
|
18
|
+
return "; ".join(self.errors) if self.errors else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SchemaValidator:
|
|
22
|
+
"""Validate dict data against a JSON Schema dict."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, schema: Dict[str, Any], strict: bool = True):
|
|
25
|
+
self.schema = schema
|
|
26
|
+
self.strict = strict
|
|
27
|
+
self._validator = Draft202012Validator(schema)
|
|
28
|
+
self._last_errors: List[str] = []
|
|
29
|
+
|
|
30
|
+
def validate(self, data: Any) -> ValidationResult:
|
|
31
|
+
self._last_errors = []
|
|
32
|
+
if not isinstance(data, (dict, list)) and self.schema.get("type") == "object":
|
|
33
|
+
self._last_errors.append("expected object")
|
|
34
|
+
return ValidationResult(False, list(self._last_errors))
|
|
35
|
+
try:
|
|
36
|
+
self._validator.validate(data)
|
|
37
|
+
return ValidationResult(True, [])
|
|
38
|
+
except jsonschema.ValidationError as e:
|
|
39
|
+
self._last_errors.append(e.message)
|
|
40
|
+
return ValidationResult(False, list(self._last_errors))
|
|
41
|
+
|
|
42
|
+
def get_error_details(self) -> Dict[str, Any]:
|
|
43
|
+
return {"errors": list(self._last_errors)}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""JSON Schema fragments for session JSONL lines and sessions.json index."""
|
|
2
|
+
|
|
3
|
+
# sessions.json 根对象:宽松,兼容 OpenClaw 多版本(仅约束为 object)
|
|
4
|
+
sessions_index_schema: dict = {
|
|
5
|
+
"$id": "https://openclaw/schemas/sessions-index/v1",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": True,
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": {},
|
|
10
|
+
"schema": {},
|
|
11
|
+
"entries": {"type": "object", "additionalProperties": True},
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Line envelope: {"type": "message", "message": {...}, ... }
|
|
16
|
+
session_message_schema: dict = {
|
|
17
|
+
"$id": "https://openclaw/schemas/session-message/v1",
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": True,
|
|
20
|
+
"properties": {
|
|
21
|
+
"role": {"type": "string"},
|
|
22
|
+
"content": {},
|
|
23
|
+
"timestamp": {"type": ["integer", "number"]},
|
|
24
|
+
"stopReason": {"type": "string"},
|
|
25
|
+
"errorMessage": {"type": "string"},
|
|
26
|
+
},
|
|
27
|
+
"required": ["role"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
session_envelope_schema: dict = {
|
|
31
|
+
"$id": "https://openclaw/schemas/session-envelope/v1",
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": True,
|
|
34
|
+
"properties": {
|
|
35
|
+
"type": {"type": "string"},
|
|
36
|
+
"message": {"type": "object", "additionalProperties": True},
|
|
37
|
+
"timestamp": {},
|
|
38
|
+
},
|
|
39
|
+
"required": ["type"],
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""JSON Schema for subagents/runs.json root object."""
|
|
2
|
+
|
|
3
|
+
subagent_runs_root_schema: dict = {
|
|
4
|
+
"$id": "https://openclaw/schemas/subagent-runs/v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": True,
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": {"type": ["integer", "number"]},
|
|
9
|
+
"runs": {"type": "object", "additionalProperties": True},
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
subagent_run_record_schema: dict = {
|
|
14
|
+
"$id": "https://openclaw/schemas/subagent-run-record/v1",
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": True,
|
|
17
|
+
"properties": {
|
|
18
|
+
"childSessionKey": {"type": "string"},
|
|
19
|
+
"requesterSessionKey": {"type": "string"},
|
|
20
|
+
"startedAt": {"type": ["integer", "number"]},
|
|
21
|
+
"endedAt": {},
|
|
22
|
+
},
|
|
23
|
+
}
|
|
@@ -8,7 +8,7 @@ import shutil
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
|
|
10
10
|
from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id, agent_ids_equal
|
|
11
|
-
from data.session_reader import normalize_sessions_index
|
|
11
|
+
from data.session_reader import normalize_sessions_index, _load_sessions_index_file
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def _backup_config() -> Optional[Path]:
|
|
@@ -230,9 +230,11 @@ def get_agent_full_info(agent_id: str) -> Dict[str, Any]:
|
|
|
230
230
|
|
|
231
231
|
if session_file.exists():
|
|
232
232
|
try:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
sessions_data = _load_sessions_index_file(session_file)
|
|
234
|
+
if not sessions_data:
|
|
235
|
+
entries = {}
|
|
236
|
+
else:
|
|
237
|
+
entries = normalize_sessions_index(sessions_data)
|
|
236
238
|
if entries:
|
|
237
239
|
latest = max(entries.values(), key=lambda e: e.get('lastMessageAt', 0))
|
|
238
240
|
last_active = latest.get('lastMessageAt')
|
|
@@ -28,6 +28,8 @@ from data.config_reader import get_openclaw_root
|
|
|
28
28
|
|
|
29
29
|
def _get_agents_config() -> Dict[str, Any]:
|
|
30
30
|
"""获取 agents 配置"""
|
|
31
|
+
from core.error_handler import record_error
|
|
32
|
+
|
|
31
33
|
config_file = get_openclaw_root() / "openclaw.json"
|
|
32
34
|
if not config_file.exists():
|
|
33
35
|
return {}
|
|
@@ -35,7 +37,8 @@ def _get_agents_config() -> Dict[str, Any]:
|
|
|
35
37
|
try:
|
|
36
38
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
37
39
|
return json.load(f)
|
|
38
|
-
except:
|
|
40
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
41
|
+
record_error("parsing-error", str(e), "chain_reader:openclaw.json", exc=e)
|
|
39
42
|
return {}
|
|
40
43
|
|
|
41
44
|
|
|
@@ -68,16 +71,15 @@ def _parse_session_key(session_key: str) -> Dict[str, str]:
|
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
def _load_runs() -> Dict[str, Any]:
|
|
71
|
-
"""加载 runs.json"""
|
|
72
|
-
|
|
73
|
-
if not runs_file.exists():
|
|
74
|
-
return {"version": 2, "runs": {}}
|
|
74
|
+
"""加载 runs.json(经 subagent_reader:schema + record_error 与全仓读路径一致)"""
|
|
75
|
+
from data.subagent_reader import load_subagent_runs
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
runs: Dict[str, Any] = {}
|
|
78
|
+
for rec in load_subagent_runs():
|
|
79
|
+
rid = rec.get("runId") or ""
|
|
80
|
+
if rid:
|
|
81
|
+
runs[rid] = rec
|
|
82
|
+
return {"version": 2, "runs": runs}
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
def _get_workflow_state(project_id: str) -> Dict[str, Any]:
|
|
@@ -88,13 +90,15 @@ def _get_workflow_state(project_id: str) -> Dict[str, Any]:
|
|
|
88
90
|
Path.home() / "vrt-projects" / "projects" / project_id / ".staging" / "workflow_state.json",
|
|
89
91
|
]
|
|
90
92
|
|
|
93
|
+
from core.error_handler import record_error
|
|
94
|
+
|
|
91
95
|
for path in possible_paths:
|
|
92
96
|
if path.exists():
|
|
93
97
|
try:
|
|
94
98
|
with open(path, 'r', encoding='utf-8') as f:
|
|
95
99
|
return json.load(f)
|
|
96
|
-
except:
|
|
97
|
-
|
|
100
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
101
|
+
record_error("parsing-error", str(e), "chain_reader:workflow_state", exc=e)
|
|
98
102
|
|
|
99
103
|
return {}
|
|
100
104
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
2
|
错误分析器 - 分析 Agent 执行错误,追溯根因
|
|
3
3
|
"""
|
|
4
|
-
import json
|
|
5
4
|
import os
|
|
6
5
|
import re
|
|
7
6
|
from pathlib import Path
|
|
@@ -32,6 +31,7 @@ class ErrorSeverity(Enum):
|
|
|
32
31
|
|
|
33
32
|
|
|
34
33
|
from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id
|
|
34
|
+
from utils.data_repair import parse_session_jsonl_line
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
# 错误模式匹配规则
|
|
@@ -187,11 +187,13 @@ def parse_session_for_errors(session_path: Path) -> List[Dict[str, Any]]:
|
|
|
187
187
|
turn_index = 0
|
|
188
188
|
for line in f:
|
|
189
189
|
try:
|
|
190
|
-
|
|
191
|
-
if
|
|
190
|
+
envelope, msg = parse_session_jsonl_line(line)
|
|
191
|
+
if (
|
|
192
|
+
envelope is None
|
|
193
|
+
or envelope.get('type') != 'message'
|
|
194
|
+
or msg is None
|
|
195
|
+
):
|
|
192
196
|
continue
|
|
193
|
-
|
|
194
|
-
msg = data.get('message', {})
|
|
195
197
|
role = msg.get('role')
|
|
196
198
|
timestamp = msg.get('timestamp')
|
|
197
199
|
stop_reason = msg.get('stopReason')
|
|
@@ -254,7 +256,7 @@ def parse_session_for_errors(session_path: Path) -> List[Dict[str, Any]]:
|
|
|
254
256
|
|
|
255
257
|
turn_index += 1
|
|
256
258
|
|
|
257
|
-
except (
|
|
259
|
+
except (KeyError, TypeError, AttributeError):
|
|
258
260
|
continue
|
|
259
261
|
|
|
260
262
|
except Exception as e:
|
|
@@ -279,11 +281,13 @@ def get_tool_call_chain(session_path: Path, before_turn: int, limit: int = 10) -
|
|
|
279
281
|
if turn_index >= before_turn:
|
|
280
282
|
break
|
|
281
283
|
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
+
envelope, msg = parse_session_jsonl_line(line)
|
|
285
|
+
if (
|
|
286
|
+
envelope is None
|
|
287
|
+
or envelope.get('type') != 'message'
|
|
288
|
+
or msg is None
|
|
289
|
+
):
|
|
284
290
|
continue
|
|
285
|
-
|
|
286
|
-
msg = data.get('message', {})
|
|
287
291
|
role = msg.get('role')
|
|
288
292
|
timestamp = msg.get('timestamp')
|
|
289
293
|
|
|
@@ -301,7 +305,7 @@ def get_tool_call_chain(session_path: Path, before_turn: int, limit: int = 10) -
|
|
|
301
305
|
|
|
302
306
|
turn_index += 1
|
|
303
307
|
|
|
304
|
-
except (
|
|
308
|
+
except (KeyError, TypeError, AttributeError):
|
|
305
309
|
continue
|
|
306
310
|
|
|
307
311
|
except Exception:
|