openclaw-agent-dashboard 1.0.39 → 1.0.40

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 (54) 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 +27 -11
  9. package/dashboard/api/fortify_routes.py +80 -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 +112 -0
  18. package/dashboard/core/error_handler.py +339 -0
  19. package/dashboard/core/fallback_manager.py +70 -0
  20. package/dashboard/core/safe_api_error.py +76 -0
  21. package/dashboard/core/schemas/__init__.py +16 -0
  22. package/dashboard/core/schemas/base.py +43 -0
  23. package/dashboard/core/schemas/session_schema.py +40 -0
  24. package/dashboard/core/schemas/subagent_schema.py +23 -0
  25. package/dashboard/data/agent_config_manager.py +6 -4
  26. package/dashboard/data/chain_reader.py +16 -12
  27. package/dashboard/data/error_analyzer.py +15 -11
  28. package/dashboard/data/session_reader.py +268 -46
  29. package/dashboard/data/subagent_reader.py +74 -49
  30. package/dashboard/data/timeline_reader.py +35 -49
  31. package/dashboard/main.py +24 -2
  32. package/dashboard/mechanism_reader.py +4 -5
  33. package/dashboard/mechanisms.py +2 -2
  34. package/dashboard/pytest.ini +3 -0
  35. package/dashboard/requirements.txt +5 -0
  36. package/dashboard/status/cache_fp_probe.py +40 -0
  37. package/dashboard/status/status_cache.py +199 -72
  38. package/dashboard/status/status_calculator.py +50 -30
  39. package/dashboard/tests/conftest.py +84 -0
  40. package/dashboard/tests/test_api_contracts.py +372 -0
  41. package/dashboard/tests/test_bench_fortify.py +176 -0
  42. package/dashboard/tests/test_fortify.py +741 -0
  43. package/dashboard/utils/__init__.py +1 -0
  44. package/dashboard/utils/data_repair.py +210 -0
  45. package/dashboard/watchers/file_watcher.py +367 -77
  46. package/openclaw.plugin.json +1 -1
  47. package/package.json +1 -1
  48. package/dashboard/agents.py +0 -74
  49. package/dashboard/collaboration.py +0 -407
  50. package/dashboard/errors.py +0 -63
  51. package/dashboard/performance.py +0 -474
  52. package/dashboard/session_reader.py +0 -240
  53. package/dashboard/status_calculator.py +0 -121
  54. package/dashboard/subagent_reader.py +0 -232
@@ -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:
@@ -1,6 +1,7 @@
1
1
  """
2
2
  会话读取器 - 读取 sessions/*.jsonl 和 sessions.json
3
3
  """
4
+ import hashlib
4
5
  import json
5
6
  import os
6
7
  from pathlib import Path
@@ -8,9 +9,73 @@ from typing import List, Dict, Any, Optional
8
9
 
9
10
 
10
11
  from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id
12
+ from utils.data_repair import parse_session_jsonl_line
11
13
 
12
14
  _META_SESSION_INDEX_KEYS = frozenset({"entries", "version", "schema"})
13
15
 
16
+ # 校验报告:大文件仅哈希尾部,与 tail 读取策略一致;小文件全量哈希
17
+ _MAX_FULL_HASH_BYTES = 4 * 1024 * 1024
18
+ _TAIL_HASH_BYTES = 512 * 1024
19
+
20
+
21
+ def compute_session_file_integrity(path: Path) -> Dict[str, Any]:
22
+ """文件级完整性元数据:size、mtime、sha256(全文件或尾部窗口)。"""
23
+ try:
24
+ st = path.stat()
25
+ except OSError as e:
26
+ return {"path": str(path), "error": f"stat_failed:{e}"}
27
+ size = int(st.st_size)
28
+ mtime_ns = int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1e9)))
29
+ out: Dict[str, Any] = {
30
+ "path": str(path.resolve()),
31
+ "size_bytes": size,
32
+ "mtime_ns": mtime_ns,
33
+ }
34
+ try:
35
+ if size == 0:
36
+ out["sha256"] = hashlib.sha256(b"").hexdigest()
37
+ out["hash_scope"] = "full"
38
+ return out
39
+ if size <= _MAX_FULL_HASH_BYTES:
40
+ with open(path, "rb") as f:
41
+ out["sha256"] = hashlib.sha256(f.read()).hexdigest()
42
+ out["hash_scope"] = "full"
43
+ else:
44
+ with open(path, "rb") as f:
45
+ f.seek(max(0, size - _TAIL_HASH_BYTES))
46
+ tail = f.read()
47
+ out["sha256"] = hashlib.sha256(tail).hexdigest()
48
+ out["hash_scope"] = "tail_512kb"
49
+ out["tail_hashed_bytes"] = len(tail)
50
+ except OSError as e:
51
+ out["error"] = f"read_failed:{e}"
52
+ return out
53
+
54
+
55
+ def resolve_validated_session_jsonl(agent_id: str, relative: str) -> Optional[Path]:
56
+ """将相对路径解析为 agents/{id}/sessions 下的 .jsonl,禁止逃逸。"""
57
+ if not relative or not relative.strip():
58
+ return None
59
+ aid = normalize_openclaw_agent_id(agent_id)
60
+ base = get_openclaw_root() / "agents" / aid / "sessions"
61
+ if not base.is_dir():
62
+ return None
63
+ try:
64
+ base_r = base.resolve()
65
+ except OSError:
66
+ return None
67
+ rel = Path(relative.strip())
68
+ if rel.is_absolute() or ".." in rel.parts:
69
+ return None
70
+ try:
71
+ cand = (base_r / rel).resolve()
72
+ cand.relative_to(base_r)
73
+ except ValueError:
74
+ return None
75
+ if not cand.is_file() or cand.suffix.lower() != ".jsonl":
76
+ return None
77
+ return cand
78
+
14
79
 
15
80
  def normalize_sessions_index(raw: Any) -> Dict[str, Dict[str, Any]]:
16
81
  """
@@ -68,6 +133,38 @@ def resolve_session_jsonl_path(sessions_dir: Path, entry: Dict[str, Any]) -> Opt
68
133
  return None
69
134
 
70
135
 
136
+ def _load_sessions_index_file(sessions_index: Path) -> Optional[Dict[str, Any]]:
137
+ """读取 sessions.json;可选 JSON Schema 校验(OPENCLAW_JSON_STRICT)。失败返回 None 并记录错误。"""
138
+ if not sessions_index.exists():
139
+ return None
140
+ try:
141
+ with open(sessions_index, "r", encoding="utf-8") as f:
142
+ data = json.load(f)
143
+ except (json.JSONDecodeError, OSError) as e:
144
+ from core.error_handler import record_error
145
+
146
+ record_error("parsing-error", str(e), "sessions_index", exc=e)
147
+ return None
148
+ if not isinstance(data, dict):
149
+ from core.error_handler import record_error
150
+
151
+ record_error("validation-error", "sessions index root is not an object", "sessions_index")
152
+ return None
153
+ from core.config_fortify import get_fortify_config
154
+ from core.schemas.base import SchemaValidator
155
+ from core.schemas.session_schema import sessions_index_schema
156
+
157
+ cfg = get_fortify_config()
158
+ vr = SchemaValidator(sessions_index_schema, strict=cfg.json_strict).validate(data)
159
+ if not vr.is_valid:
160
+ from core.error_handler import record_error
161
+
162
+ record_error("validation-error", vr.error_message, "sessions_index")
163
+ if cfg.json_strict:
164
+ return None
165
+ return data
166
+
167
+
71
168
  def get_agent_sessions_path(agent_id: str) -> Optional[Path]:
72
169
  """获取 Agent 的 sessions 目录"""
73
170
  sessions_path = get_openclaw_root() / "agents" / normalize_openclaw_agent_id(agent_id) / "sessions"
@@ -125,12 +222,9 @@ def get_recent_messages(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
125
222
  line = line.strip()
126
223
  if not line:
127
224
  continue
128
- try:
129
- data = json.loads(line)
130
- if data.get('type') == 'message':
131
- messages.append(data.get('message', {}))
132
- except json.JSONDecodeError:
133
- continue
225
+ _, msg = parse_session_jsonl_line(line)
226
+ if msg is not None:
227
+ messages.append(msg)
134
228
  # 必须取尾部:原先在扫描到 limit 条就 break,会拿到「窗口内较早」的消息而非最新,导致 tool/ thinking 误判
135
229
  return messages[-limit:] if len(messages) > limit else messages
136
230
 
@@ -214,21 +308,17 @@ def get_session_updated_at(agent_id: str) -> int:
214
308
  sessions_index = get_openclaw_root() / "agents" / aid / "sessions" / "sessions.json"
215
309
  if not sessions_index.exists():
216
310
  return 0
217
-
218
- try:
219
- with open(sessions_index, 'r', encoding='utf-8') as f:
220
- data = json.load(f)
221
- if not isinstance(data, dict):
222
- return 0
223
- index_map = normalize_sessions_index(data)
224
- max_ts = 0
225
- for entry in index_map.values():
226
- ts = entry.get('updatedAt') or entry.get('lastMessageAt') or 0
227
- if isinstance(ts, (int, float)) and ts > max_ts:
228
- max_ts = int(ts)
229
- return max_ts
230
- except (json.JSONDecodeError, IOError):
311
+
312
+ data = _load_sessions_index_file(sessions_index)
313
+ if not data:
231
314
  return 0
315
+ index_map = normalize_sessions_index(data)
316
+ max_ts = 0
317
+ for entry in index_map.values():
318
+ ts = entry.get("updatedAt") or entry.get("lastMessageAt") or 0
319
+ if isinstance(ts, (int, float)) and ts > max_ts:
320
+ max_ts = int(ts)
321
+ return max_ts
232
322
 
233
323
 
234
324
  def has_recent_session_activity(agent_id: str, minutes: int = 5) -> bool:
@@ -269,15 +359,12 @@ def get_session_turns(agent_id: str, session_key: Optional[str] = None, limit: i
269
359
  session_file: Optional[Path] = None
270
360
  sessions_path = get_openclaw_root() / "agents" / aid / "sessions"
271
361
  if session_key:
272
- try:
273
- with open(sessions_index, 'r', encoding='utf-8') as f:
274
- index_data = json.load(f)
362
+ index_data = _load_sessions_index_file(sessions_index)
363
+ if index_data:
275
364
  index_map = normalize_sessions_index(index_data)
276
365
  entry = index_map.get(session_key)
277
366
  if entry:
278
367
  session_file = resolve_session_jsonl_path(sessions_path, entry)
279
- except (json.JSONDecodeError, IOError):
280
- pass
281
368
 
282
369
  if not session_file or not session_file.exists():
283
370
  session_file = get_latest_session_file(agent_id)
@@ -290,19 +377,18 @@ def get_session_turns(agent_id: str, session_key: Optional[str] = None, limit: i
290
377
 
291
378
  with open(session_file, 'r', encoding='utf-8') as f:
292
379
  for line in f:
380
+ envelope, msg = parse_session_jsonl_line(line.strip())
381
+ if not envelope or envelope.get('type') != 'message' or msg is None:
382
+ continue
383
+ role = msg.get('role')
384
+ if not role:
385
+ continue
386
+
293
387
  try:
294
- data = json.loads(line.strip())
295
- if data.get('type') != 'message':
296
- continue
297
- msg = data.get('message', {})
298
- role = msg.get('role')
299
- if not role:
300
- continue
301
-
302
388
  turn: Dict[str, Any] = {
303
389
  'turnIndex': turn_index,
304
390
  'role': role,
305
- 'timestamp': msg.get('timestamp') or data.get('timestamp'),
391
+ 'timestamp': msg.get('timestamp') or envelope.get('timestamp'),
306
392
  'content': [],
307
393
  'usage': msg.get('usage'),
308
394
  'stopReason': msg.get('stopReason'),
@@ -350,8 +436,8 @@ def get_session_turns(agent_id: str, session_key: Optional[str] = None, limit: i
350
436
 
351
437
  turns.append(turn)
352
438
  turn_index += 1
353
-
354
- except (json.JSONDecodeError, KeyError):
439
+
440
+ except (KeyError, TypeError):
355
441
  continue
356
442
 
357
443
  return turns[-limit:] if len(turns) > limit else turns
@@ -471,17 +557,14 @@ def get_recent_messages_with_timestamp(agent_id: str, limit: int = 10) -> List[D
471
557
  line = line.strip()
472
558
  if not line:
473
559
  continue
474
- try:
475
- data = json.loads(line)
476
- if data.get('type') == 'message':
477
- msg = data.get('message', {})
478
- messages.append({
479
- 'message': msg,
480
- 'timestamp': msg.get('timestamp', 0),
481
- 'data_timestamp': data.get('timestamp', ''),
482
- })
483
- except json.JSONDecodeError:
560
+ env, msg = parse_session_jsonl_line(line)
561
+ if msg is None:
484
562
  continue
563
+ messages.append({
564
+ 'message': msg,
565
+ 'timestamp': msg.get('timestamp', 0),
566
+ 'data_timestamp': (env or {}).get('timestamp', ''),
567
+ })
485
568
 
486
569
  return messages[-limit:] if len(messages) > limit else messages
487
570
 
@@ -545,3 +628,142 @@ def get_pending_tool_call_with_timestamp(agent_id: str) -> Optional[Dict[str, An
545
628
  return tool_calls[last_id]
546
629
 
547
630
  return None
631
+
632
+
633
+ def get_session_validation_report(
634
+ agent_id: str,
635
+ *,
636
+ relative_session_file: Optional[str] = None,
637
+ auto_repair: Optional[bool] = None,
638
+ include_details: bool = False,
639
+ max_lines: int = 1000,
640
+ ) -> Dict[str, Any]:
641
+ """Validate session JSONL for an agent; used by GET /api/data/validate."""
642
+ from core.config_fortify import get_fortify_config
643
+
644
+ aid = normalize_openclaw_agent_id(agent_id)
645
+ sessions_dir = get_openclaw_root() / "agents" / aid / "sessions"
646
+ sessions_index_path = sessions_dir / "sessions.json"
647
+ cfg = get_fortify_config()
648
+ read_path_policy = {
649
+ "memory_auto_repair_default": cfg.auto_repair_json,
650
+ "disk_write_back_enabled": cfg.auto_repair_write_back,
651
+ "note": "本报告仅校验与统计;读路径不自动写盘,写回仅在显式修复工具中且受 OPENCLAW_AUTO_REPAIR_WB 控制。",
652
+ }
653
+
654
+ session_file: Optional[Path] = None
655
+ if relative_session_file and relative_session_file.strip():
656
+ session_file = resolve_validated_session_jsonl(agent_id, relative_session_file)
657
+ if not session_file:
658
+ return {
659
+ "agent_id": agent_id,
660
+ "validation_passed": False,
661
+ "sessions_index_path": str(sessions_index_path)
662
+ if sessions_index_path.exists()
663
+ else None,
664
+ "session_file": None,
665
+ "session_file_query": relative_session_file.strip(),
666
+ "file_integrity": None,
667
+ "read_path_policy": read_path_policy,
668
+ "total_lines": 0,
669
+ "valid_messages": 0,
670
+ "repaired_messages": 0,
671
+ "errors": [
672
+ {
673
+ "type": "invalid_session_file",
674
+ "message": "path not found, not .jsonl, or escapes sessions dir",
675
+ }
676
+ ],
677
+ "repair_report": {
678
+ "repaired_count": 0,
679
+ "repair_success_rate": 1.0,
680
+ "failed_repairs": [],
681
+ },
682
+ }
683
+ else:
684
+ session_file = get_latest_session_file(agent_id)
685
+
686
+ if not session_file:
687
+ return {
688
+ "agent_id": agent_id,
689
+ "validation_passed": True,
690
+ "sessions_index_path": str(sessions_index_path)
691
+ if sessions_index_path.exists()
692
+ else None,
693
+ "session_file": None,
694
+ "session_file_query": None,
695
+ "file_integrity": None,
696
+ "read_path_policy": read_path_policy,
697
+ "total_lines": 0,
698
+ "valid_messages": 0,
699
+ "repaired_messages": 0,
700
+ "errors": [],
701
+ "repair_report": {"repaired_count": 0, "repair_success_rate": 1.0, "failed_repairs": []},
702
+ }
703
+
704
+ raw_lines = _read_tail_lines(session_file, max_lines)
705
+ errors: List[Dict[str, Any]] = []
706
+ valid_messages = 0
707
+ repaired_messages = 0
708
+ failed_repairs: List[Dict[str, Any]] = []
709
+
710
+ for i, line in enumerate(raw_lines):
711
+ stripped = line.strip()
712
+ if not stripped:
713
+ continue
714
+ env_probe, _ = parse_session_jsonl_line(
715
+ stripped, auto_repair=False, json_strict=False
716
+ )
717
+ if env_probe and env_probe.get("type") != "message":
718
+ continue
719
+ _env_strict, msg_strict = parse_session_jsonl_line(
720
+ stripped, auto_repair=False, json_strict=True
721
+ )
722
+ if msg_strict is not None:
723
+ valid_messages += 1
724
+ continue
725
+ _env_loose, msg_loose = parse_session_jsonl_line(
726
+ stripped,
727
+ auto_repair=auto_repair if auto_repair is not None else True,
728
+ json_strict=False,
729
+ )
730
+ if msg_loose is not None:
731
+ repaired_messages += 1
732
+ else:
733
+ failed_repairs.append({"line_index": i, "reason": "unparseable_or_invalid_schema"})
734
+ if include_details:
735
+ errors.append(
736
+ {
737
+ "type": "validation_failed",
738
+ "line_index_in_tail_window": i,
739
+ "reason": "unparseable_or_invalid_schema",
740
+ "sample": stripped[:200],
741
+ }
742
+ )
743
+
744
+ total_non_empty = sum(1 for ln in raw_lines if ln.strip())
745
+ validation_passed = len(failed_repairs) == 0
746
+ denom = repaired_messages + valid_messages
747
+ rate = 1.0 if denom == 0 else repaired_messages / max(denom, 1)
748
+
749
+ return {
750
+ "agent_id": agent_id,
751
+ "validation_passed": validation_passed,
752
+ "sessions_index_path": str(sessions_index_path)
753
+ if sessions_index_path.exists()
754
+ else None,
755
+ "session_file": str(session_file.resolve()),
756
+ "session_file_query": relative_session_file.strip() if relative_session_file else None,
757
+ "file_integrity": compute_session_file_integrity(session_file),
758
+ "read_path_policy": read_path_policy,
759
+ "tail_lines_scanned": max_lines,
760
+ "total_lines": total_non_empty,
761
+ "valid_messages": valid_messages,
762
+ "repaired_messages": repaired_messages,
763
+ "errors": errors,
764
+ "repair_report": {
765
+ "repaired_count": repaired_messages,
766
+ "repair_success_rate": rate,
767
+ "failed_repairs": failed_repairs,
768
+ },
769
+ }