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.
Files changed (58) hide show
  1. package/dashboard/api/agent_config_api.py +28 -7
  2. package/dashboard/api/agents.py +48 -10
  3. package/dashboard/api/agents_config.py +5 -1
  4. package/dashboard/api/chains.py +25 -5
  5. package/dashboard/api/collaboration.py +10 -9
  6. package/dashboard/api/debug_paths.py +5 -1
  7. package/dashboard/api/error_analysis.py +29 -11
  8. package/dashboard/api/errors.py +37 -11
  9. package/dashboard/api/fortify_routes.py +108 -0
  10. package/dashboard/api/input_safety.py +60 -0
  11. package/dashboard/api/performance.py +73 -53
  12. package/dashboard/api/subagents.py +95 -99
  13. package/dashboard/api/timeline.py +24 -3
  14. package/dashboard/api/version.py +2 -0
  15. package/dashboard/api/websocket.py +9 -7
  16. package/dashboard/core/__init__.py +1 -0
  17. package/dashboard/core/config_fortify.py +125 -0
  18. package/dashboard/core/error_handler.py +488 -0
  19. package/dashboard/core/fallback_manager.py +81 -0
  20. package/dashboard/core/logging_config.py +217 -0
  21. package/dashboard/core/safe_api_error.py +76 -0
  22. package/dashboard/core/schemas/__init__.py +16 -0
  23. package/dashboard/core/schemas/base.py +43 -0
  24. package/dashboard/core/schemas/session_schema.py +40 -0
  25. package/dashboard/core/schemas/subagent_schema.py +23 -0
  26. package/dashboard/data/agent_config_manager.py +6 -4
  27. package/dashboard/data/chain_reader.py +16 -12
  28. package/dashboard/data/error_analyzer.py +15 -11
  29. package/dashboard/data/session_reader.py +268 -46
  30. package/dashboard/data/subagent_reader.py +74 -49
  31. package/dashboard/data/timeline_reader.py +35 -49
  32. package/dashboard/main.py +24 -2
  33. package/dashboard/mechanism_reader.py +4 -5
  34. package/dashboard/mechanisms.py +2 -2
  35. package/dashboard/pytest.ini +3 -0
  36. package/dashboard/requirements.txt +5 -0
  37. package/dashboard/status/cache_fp_probe.py +40 -0
  38. package/dashboard/status/status_cache.py +199 -72
  39. package/dashboard/status/status_calculator.py +50 -30
  40. package/dashboard/tests/conftest.py +87 -0
  41. package/dashboard/tests/test_api_contracts.py +372 -0
  42. package/dashboard/tests/test_bench_fortify.py +176 -0
  43. package/dashboard/tests/test_fortify.py +952 -0
  44. package/dashboard/utils/__init__.py +1 -0
  45. package/dashboard/utils/data_repair.py +210 -0
  46. package/dashboard/watchers/file_watcher.py +380 -77
  47. package/frontend-dist/assets/{index-cYIOn3Wq.css → index-BIZ2xHfw.css} +1 -1
  48. package/frontend-dist/assets/{index-DyRXGevD.js → index-Cnr0b02R.js} +1 -1
  49. package/frontend-dist/index.html +2 -2
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
  52. package/dashboard/agents.py +0 -74
  53. package/dashboard/collaboration.py +0 -407
  54. package/dashboard/errors.py +0 -63
  55. package/dashboard/performance.py +0 -474
  56. package/dashboard/session_reader.py +0 -240
  57. package/dashboard/status_calculator.py +0 -121
  58. 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
- with open(session_file, 'r', encoding='utf-8') as f:
234
- sessions_data = json.load(f)
235
- entries = normalize_sessions_index(sessions_data)
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
- runs_file = get_openclaw_root() / "subagents" / "runs.json"
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
- try:
77
- with open(runs_file, 'r', encoding='utf-8') as f:
78
- return json.load(f)
79
- except:
80
- return {"version": 2, "runs": {}}
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
- pass
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
- data = json.loads(line.strip())
191
- if data.get('type') != 'message':
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 (json.JSONDecodeError, KeyError):
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
- data = json.loads(line.strip())
283
- if data.get('type') != 'message':
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 (json.JSONDecodeError, KeyError):
308
+ except (KeyError, TypeError, AttributeError):
305
309
  continue
306
310
 
307
311
  except Exception: