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.
- 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 +27 -11
- package/dashboard/api/fortify_routes.py +80 -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 +112 -0
- package/dashboard/core/error_handler.py +339 -0
- package/dashboard/core/fallback_manager.py +70 -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 +84 -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 +741 -0
- package/dashboard/utils/__init__.py +1 -0
- package/dashboard/utils/data_repair.py +210 -0
- package/dashboard/watchers/file_watcher.py +367 -77
- 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,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:
|
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
475
|
-
|
|
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
|
+
}
|