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
|
@@ -77,48 +77,68 @@ def calculate_agent_status(agent_id: str, use_cache: bool = True) -> AgentStatus
|
|
|
77
77
|
cached = cache.get(agent_id)
|
|
78
78
|
if cached and 'status' in cached:
|
|
79
79
|
return cached['status']
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# 重新计算
|
|
83
|
+
if has_recent_errors(agent_id, minutes=5):
|
|
84
|
+
status = 'down'
|
|
85
|
+
elif is_agent_working(agent_id):
|
|
86
|
+
status = 'working'
|
|
87
|
+
elif _main_agent_solo_processing(agent_id):
|
|
88
|
+
status = 'working'
|
|
89
|
+
else:
|
|
90
|
+
status = 'idle'
|
|
91
|
+
except OSError as e:
|
|
92
|
+
from core.error_handler import classify_exception, record_error
|
|
93
|
+
from core.fallback_manager import run_fallback
|
|
94
|
+
|
|
95
|
+
cat = classify_exception(e)
|
|
96
|
+
record_error(cat, str(e), f"status_calculator:calculate:{agent_id}", exc=e)
|
|
97
|
+
fb = run_fallback(cat, agent_id=agent_id)
|
|
98
|
+
if fb is not None:
|
|
99
|
+
return fb # type: ignore[return-value]
|
|
100
|
+
return 'idle'
|
|
101
|
+
|
|
94
102
|
# 更新缓存(只缓存状态)
|
|
95
103
|
if use_cache:
|
|
96
104
|
cache = get_cache()
|
|
97
105
|
cache.set(agent_id, {'status': status})
|
|
98
|
-
|
|
106
|
+
|
|
99
107
|
return status
|
|
100
108
|
|
|
101
109
|
|
|
102
110
|
def get_agents_with_status() -> list:
|
|
103
111
|
"""获取所有 Agent 及其状态"""
|
|
104
|
-
|
|
112
|
+
try:
|
|
113
|
+
agents = get_agents_list()
|
|
114
|
+
except OSError as e:
|
|
115
|
+
from core.error_handler import classify_exception, record_error
|
|
116
|
+
|
|
117
|
+
record_error(classify_exception(e), str(e), "get_agents_with_status:list", exc=e)
|
|
118
|
+
return []
|
|
119
|
+
|
|
105
120
|
result = []
|
|
106
|
-
|
|
121
|
+
|
|
107
122
|
for agent in agents:
|
|
108
123
|
agent_id = agent.get('id')
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
try:
|
|
125
|
+
status = calculate_agent_status(agent_id)
|
|
126
|
+
current_task = get_current_task(agent_id)
|
|
127
|
+
if status == 'idle':
|
|
128
|
+
current_task = ''
|
|
129
|
+
last_active = get_last_active_time(agent_id)
|
|
130
|
+
last_error = get_last_error(agent_id) if status == 'down' else None
|
|
131
|
+
except OSError as e:
|
|
132
|
+
from core.error_handler import classify_exception, record_error
|
|
133
|
+
from core.fallback_manager import run_fallback
|
|
134
|
+
|
|
135
|
+
cat = classify_exception(e)
|
|
136
|
+
record_error(cat, str(e), f"get_agents_with_status:{agent_id}", exc=e)
|
|
137
|
+
status = run_fallback(cat, agent_id=agent_id) or 'idle'
|
|
114
138
|
current_task = ''
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# 获取错误信息
|
|
120
|
-
last_error = get_last_error(agent_id) if status == 'down' else None
|
|
121
|
-
|
|
139
|
+
last_active = 0
|
|
140
|
+
last_error = None
|
|
141
|
+
|
|
122
142
|
result.append({
|
|
123
143
|
'id': agent_id,
|
|
124
144
|
'name': agent.get('name'),
|
|
@@ -128,7 +148,7 @@ def get_agents_with_status() -> list:
|
|
|
128
148
|
'lastActiveAt': last_active,
|
|
129
149
|
'error': last_error
|
|
130
150
|
})
|
|
131
|
-
|
|
151
|
+
|
|
132
152
|
return result
|
|
133
153
|
|
|
134
154
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Shared pytest fixtures for backend tests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
BACKEND = Path(__file__).resolve().parent.parent
|
|
11
|
+
sys.path.insert(0, str(BACKEND))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def reset_fortify_state():
|
|
16
|
+
"""Reset all fortify singletons between tests."""
|
|
17
|
+
from core.config_fortify import refresh_fortify_config_cache
|
|
18
|
+
from core.error_handler import reset_reliability_metrics_for_tests
|
|
19
|
+
from core.fallback_manager import reset_fallback_handlers_for_tests
|
|
20
|
+
from status.status_cache import reset_cache_for_tests
|
|
21
|
+
|
|
22
|
+
reset_cache_for_tests()
|
|
23
|
+
reset_fallback_handlers_for_tests()
|
|
24
|
+
reset_reliability_metrics_for_tests()
|
|
25
|
+
refresh_fortify_config_cache()
|
|
26
|
+
yield
|
|
27
|
+
reset_cache_for_tests()
|
|
28
|
+
reset_fallback_handlers_for_tests()
|
|
29
|
+
reset_reliability_metrics_for_tests()
|
|
30
|
+
refresh_fortify_config_cache()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def fake_openclaw_root(tmp_path: Path):
|
|
35
|
+
"""Minimal fake openclaw root with sessions.json index and JSONL fixtures."""
|
|
36
|
+
root = tmp_path / ".openclaw"
|
|
37
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
agents_dir = root / "agents"
|
|
40
|
+
agents_dir.mkdir(exist_ok=True)
|
|
41
|
+
|
|
42
|
+
main_agent = agents_dir / "main"
|
|
43
|
+
main_agent.mkdir(exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# sessions/ subdirectory (canonical path: agents/<id>/sessions/)
|
|
46
|
+
sessions_dir = main_agent / "sessions"
|
|
47
|
+
sessions_dir.mkdir(exist_ok=True)
|
|
48
|
+
|
|
49
|
+
# sessions.json index
|
|
50
|
+
sessions_index = {
|
|
51
|
+
"sessions": [
|
|
52
|
+
{
|
|
53
|
+
"id": "session-001",
|
|
54
|
+
"status": "active",
|
|
55
|
+
"updatedAt": 1746000000,
|
|
56
|
+
"turns": 3,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "session-002",
|
|
60
|
+
"status": "completed",
|
|
61
|
+
"updatedAt": 1745900000,
|
|
62
|
+
"turns": 7,
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
sessions_file = sessions_dir / "sessions.json"
|
|
67
|
+
sessions_file.write_text(json.dumps(sessions_index))
|
|
68
|
+
|
|
69
|
+
# JSONL session file (in sessions/ subdirectory)
|
|
70
|
+
session_jsonl = sessions_dir / "session-001.jsonl"
|
|
71
|
+
messages = [
|
|
72
|
+
{"type": "start", "sessionId": "session-001", "timestamp": 1746000000},
|
|
73
|
+
{"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": "hello"}]}},
|
|
74
|
+
{"type": "message", "message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}},
|
|
75
|
+
]
|
|
76
|
+
session_jsonl.write_text("\n".join(json.dumps(m) for m in messages) + "\n")
|
|
77
|
+
|
|
78
|
+
# JSONL with repaired line (trailing comma) — CA-003 fixture
|
|
79
|
+
session_with_bad = sessions_dir / "session-002.jsonl"
|
|
80
|
+
bad_messages = [
|
|
81
|
+
json.dumps({"type": "start", "sessionId": "session-002"}),
|
|
82
|
+
'{"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": "test"}]}}',
|
|
83
|
+
'{"type": "end", "sessionId": "session-002", "status": "ok"}',
|
|
84
|
+
]
|
|
85
|
+
session_with_bad.write_text("\n".join(bad_messages) + "\n")
|
|
86
|
+
|
|
87
|
+
return root
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""CA-001 / CA-002 / CA-003:核心 API 与数据校验响应结构契约(轻量回归)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
BACKEND = Path(__file__).resolve().parent.parent
|
|
10
|
+
sys.path.insert(0, str(BACKEND))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _stub_watcher(monkeypatch):
|
|
14
|
+
import watchers.file_watcher as fw
|
|
15
|
+
monkeypatch.setattr(fw, "start_file_watcher", lambda loop: None)
|
|
16
|
+
monkeypatch.setattr(fw, "stop_file_watcher", lambda: None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_contract_health_root(monkeypatch):
|
|
21
|
+
import httpx
|
|
22
|
+
_stub_watcher(monkeypatch)
|
|
23
|
+
from main import app
|
|
24
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
25
|
+
r = await c.get("/health")
|
|
26
|
+
assert r.status_code == 200
|
|
27
|
+
assert r.json().get("status") == "healthy"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_contract_api_version_shape(monkeypatch):
|
|
32
|
+
import httpx
|
|
33
|
+
_stub_watcher(monkeypatch)
|
|
34
|
+
from main import app
|
|
35
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
36
|
+
r = await c.get("/api/version")
|
|
37
|
+
assert r.status_code == 200
|
|
38
|
+
body = r.json()
|
|
39
|
+
assert "version" in body and "name" in body and "description" in body
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_contract_cache_stats_fortify_keys(monkeypatch):
|
|
44
|
+
import httpx
|
|
45
|
+
_stub_watcher(monkeypatch)
|
|
46
|
+
from main import app
|
|
47
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
48
|
+
r = await c.get("/api/cache/stats")
|
|
49
|
+
assert r.status_code == 200
|
|
50
|
+
j = r.json()
|
|
51
|
+
for k in ("hit_rate", "ttl_seconds", "stats", "cache_double_check", "fp_probe_interval_sec"):
|
|
52
|
+
assert k in j, f"missing {k}"
|
|
53
|
+
assert "hits" in j["stats"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_contract_errors_stats_has_framework(monkeypatch):
|
|
58
|
+
import httpx
|
|
59
|
+
_stub_watcher(monkeypatch)
|
|
60
|
+
from main import app
|
|
61
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
62
|
+
r = await c.get("/api/errors/stats")
|
|
63
|
+
assert r.status_code == 200
|
|
64
|
+
j = r.json()
|
|
65
|
+
assert "framework" in j
|
|
66
|
+
fw = j["framework"]
|
|
67
|
+
assert "total_count" in fw
|
|
68
|
+
assert "retry_budget_blocks" in fw
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_contract_agents_list_is_array(monkeypatch):
|
|
73
|
+
import httpx
|
|
74
|
+
_stub_watcher(monkeypatch)
|
|
75
|
+
from main import app
|
|
76
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
77
|
+
r = await c.get("/api/agents")
|
|
78
|
+
assert r.status_code == 200
|
|
79
|
+
data = r.json()
|
|
80
|
+
assert isinstance(data, list)
|
|
81
|
+
if data:
|
|
82
|
+
row = data[0]
|
|
83
|
+
for k in ("id", "status", "name"):
|
|
84
|
+
assert k in row
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_contract_health_watcher_shape(monkeypatch):
|
|
89
|
+
import httpx
|
|
90
|
+
_stub_watcher(monkeypatch)
|
|
91
|
+
from main import app
|
|
92
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
93
|
+
r = await c.get("/api/health/watcher")
|
|
94
|
+
assert r.status_code == 200
|
|
95
|
+
j = r.json()
|
|
96
|
+
for k in (
|
|
97
|
+
"status",
|
|
98
|
+
"mode",
|
|
99
|
+
"error_count",
|
|
100
|
+
"switch_count",
|
|
101
|
+
"resume_watchdog_success_count",
|
|
102
|
+
"resume_watchdog_failure_count",
|
|
103
|
+
"uptime_seconds",
|
|
104
|
+
"poll_interval_sec",
|
|
105
|
+
"persisted_snapshot",
|
|
106
|
+
):
|
|
107
|
+
assert k in j, f"missing {k}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_contract_api_config_has_main_agent_id(monkeypatch):
|
|
112
|
+
import httpx
|
|
113
|
+
_stub_watcher(monkeypatch)
|
|
114
|
+
from main import app
|
|
115
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
116
|
+
r = await c.get("/api/config")
|
|
117
|
+
assert r.status_code == 200
|
|
118
|
+
j = r.json()
|
|
119
|
+
assert "mainAgentId" in j
|
|
120
|
+
assert isinstance(j["mainAgentId"], str)
|
|
121
|
+
assert j["mainAgentId"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_contract_data_validate_report_shape(monkeypatch, tmp_path):
|
|
126
|
+
"""CA-003:GET /api/data/validate 稳定字段(与 session_reader 报告结构对齐)。"""
|
|
127
|
+
import httpx
|
|
128
|
+
import data.session_reader as session_reader
|
|
129
|
+
monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
|
|
130
|
+
_stub_watcher(monkeypatch)
|
|
131
|
+
from main import app
|
|
132
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
133
|
+
r = await c.get("/api/data/validate", params={"agent_id": "main"})
|
|
134
|
+
assert r.status_code == 200
|
|
135
|
+
j = r.json()
|
|
136
|
+
for k in (
|
|
137
|
+
"agent_id",
|
|
138
|
+
"validation_passed",
|
|
139
|
+
"sessions_index_path",
|
|
140
|
+
"session_file",
|
|
141
|
+
"session_file_query",
|
|
142
|
+
"file_integrity",
|
|
143
|
+
"read_path_policy",
|
|
144
|
+
"total_lines",
|
|
145
|
+
"valid_messages",
|
|
146
|
+
"repaired_messages",
|
|
147
|
+
"errors",
|
|
148
|
+
"repair_report",
|
|
149
|
+
):
|
|
150
|
+
assert k in j, f"missing {k}"
|
|
151
|
+
rp = j["repair_report"]
|
|
152
|
+
assert "repaired_count" in rp and "repair_success_rate" in rp and "failed_repairs" in rp
|
|
153
|
+
assert isinstance(j["errors"], list)
|
|
154
|
+
assert isinstance(rp["failed_repairs"], list)
|
|
155
|
+
pol = j["read_path_policy"]
|
|
156
|
+
assert "memory_auto_repair_default" in pol and "disk_write_back_enabled" in pol
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# CA-001 / CA-002:timeline / chains 响应结构契约
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _stub_root(monkeypatch, tmp_path):
|
|
164
|
+
"""隔离 openclaw_root,避免依赖真实数据目录。"""
|
|
165
|
+
_stub_watcher(monkeypatch)
|
|
166
|
+
import data.session_reader as sr
|
|
167
|
+
import api.timeline as tl
|
|
168
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: tmp_path)
|
|
169
|
+
monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.asyncio
|
|
173
|
+
async def test_contract_timeline_shape(monkeypatch, tmp_path):
|
|
174
|
+
"""CA-002:GET /timeline/{agent_id} 稳定字段(agentId / steps / stats / model / agentName)。"""
|
|
175
|
+
import httpx
|
|
176
|
+
_stub_root(monkeypatch, tmp_path)
|
|
177
|
+
from main import app
|
|
178
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
179
|
+
r = await c.get("/api/timeline/main")
|
|
180
|
+
assert r.status_code == 200
|
|
181
|
+
j = r.json()
|
|
182
|
+
for k in ("agentId", "status", "steps", "stats"):
|
|
183
|
+
assert k in j, f"missing {k}"
|
|
184
|
+
stats = j["stats"]
|
|
185
|
+
for k in ("totalDuration", "totalInputTokens", "totalOutputTokens", "toolCallCount", "stepCount"):
|
|
186
|
+
assert k in stats, f"missing stats.{k}"
|
|
187
|
+
assert isinstance(j["steps"], list)
|
|
188
|
+
assert isinstance(j["agentId"], str)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.asyncio
|
|
192
|
+
async def test_contract_timeline_steps_shape(monkeypatch, tmp_path):
|
|
193
|
+
"""CA-002:GET /timeline/{agent_id}/steps 返回 {steps, count}。"""
|
|
194
|
+
import httpx
|
|
195
|
+
_stub_root(monkeypatch, tmp_path)
|
|
196
|
+
from main import app
|
|
197
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
198
|
+
r = await c.get("/api/timeline/main/steps")
|
|
199
|
+
assert r.status_code == 200
|
|
200
|
+
j = r.json()
|
|
201
|
+
assert "steps" in j
|
|
202
|
+
assert "count" in j
|
|
203
|
+
assert j["count"] == len(j["steps"])
|
|
204
|
+
assert isinstance(j["steps"], list)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@pytest.mark.asyncio
|
|
208
|
+
async def test_contract_chains_list_shape(monkeypatch, tmp_path):
|
|
209
|
+
"""CA-002:GET /chains 返回 {chains, activeChain}。"""
|
|
210
|
+
import httpx
|
|
211
|
+
_stub_root(monkeypatch, tmp_path)
|
|
212
|
+
from main import app
|
|
213
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
214
|
+
r = await c.get("/api/chains")
|
|
215
|
+
assert r.status_code == 200
|
|
216
|
+
j = r.json()
|
|
217
|
+
assert "chains" in j
|
|
218
|
+
assert "activeChain" in j
|
|
219
|
+
assert isinstance(j["chains"], list)
|
|
220
|
+
assert j["activeChain"] is None or isinstance(j["activeChain"], dict)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_contract_chains_summary_shape(monkeypatch, tmp_path):
|
|
225
|
+
"""CA-002:GET /chains/summary 返回 {total, running, completed, error, chains}。"""
|
|
226
|
+
import httpx
|
|
227
|
+
_stub_root(monkeypatch, tmp_path)
|
|
228
|
+
from main import app
|
|
229
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
230
|
+
r = await c.get("/api/chains/summary")
|
|
231
|
+
assert r.status_code == 200
|
|
232
|
+
j = r.json()
|
|
233
|
+
for k in ("total", "running", "completed", "error", "chains"):
|
|
234
|
+
assert k in j, f"missing {k}"
|
|
235
|
+
assert isinstance(j["total"], int)
|
|
236
|
+
assert isinstance(j["chains"], list)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.asyncio
|
|
240
|
+
async def test_contract_chains_active_shape(monkeypatch, tmp_path):
|
|
241
|
+
"""CA-002:GET /chains/active 返回 {activeChain} 或含 message。"""
|
|
242
|
+
import httpx
|
|
243
|
+
_stub_root(monkeypatch, tmp_path)
|
|
244
|
+
from main import app
|
|
245
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
246
|
+
r = await c.get("/api/chains/active")
|
|
247
|
+
assert r.status_code == 200
|
|
248
|
+
j = r.json()
|
|
249
|
+
assert "activeChain" in j
|
|
250
|
+
if j["activeChain"] is None:
|
|
251
|
+
assert "message" in j
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# CA-003:数据校验 fixture 驱动测试
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.asyncio
|
|
260
|
+
async def test_contract_data_validate_with_fixture(monkeypatch, fake_openclaw_root):
|
|
261
|
+
"""CA-003:GET /api/data/validate 用真实 fixture 验证 sessions.json + JSONL 解析。"""
|
|
262
|
+
import httpx
|
|
263
|
+
import data.session_reader as sr
|
|
264
|
+
import api.timeline as tl
|
|
265
|
+
|
|
266
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: fake_openclaw_root)
|
|
267
|
+
monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
|
|
268
|
+
_stub_watcher(monkeypatch)
|
|
269
|
+
from main import app
|
|
270
|
+
|
|
271
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
272
|
+
r = await c.get("/api/data/validate", params={"agent_id": "main"})
|
|
273
|
+
assert r.status_code == 200, r.text
|
|
274
|
+
j = r.json()
|
|
275
|
+
# sessions_index 被找到
|
|
276
|
+
assert j["sessions_index_path"] is not None
|
|
277
|
+
# JSONL 行被解析(valid_messages = lines with type=message)
|
|
278
|
+
assert j["total_lines"] >= 3
|
|
279
|
+
assert j["valid_messages"] >= 1
|
|
280
|
+
assert "file_integrity" in j
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_contract_data_validate_session_file_param(monkeypatch, fake_openclaw_root):
|
|
285
|
+
"""CA-003:GET /api/data/validate 支持 session_file query 参数(无 agent_id 时走默认)。"""
|
|
286
|
+
import httpx
|
|
287
|
+
import data.session_reader as sr
|
|
288
|
+
import api.timeline as tl
|
|
289
|
+
|
|
290
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: fake_openclaw_root)
|
|
291
|
+
monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
|
|
292
|
+
_stub_watcher(monkeypatch)
|
|
293
|
+
from main import app
|
|
294
|
+
|
|
295
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
296
|
+
r = await c.get("/api/data/validate", params={"agent_id": "main", "max_lines": 5})
|
|
297
|
+
assert r.status_code == 200
|
|
298
|
+
j = r.json()
|
|
299
|
+
assert "validation_passed" in j
|
|
300
|
+
assert "repair_report" in j
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# CA-002 扩展:agents / cache / errors 更多字段断言
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_contract_agents_list_item_extended_fields(monkeypatch):
|
|
310
|
+
"""CA-002:GET /api/agents 列表项含 status / name / role。"""
|
|
311
|
+
import httpx
|
|
312
|
+
_stub_watcher(monkeypatch)
|
|
313
|
+
from main import app
|
|
314
|
+
|
|
315
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
316
|
+
r = await c.get("/api/agents")
|
|
317
|
+
assert r.status_code == 200
|
|
318
|
+
data = r.json()
|
|
319
|
+
assert isinstance(data, list)
|
|
320
|
+
if data:
|
|
321
|
+
row = data[0]
|
|
322
|
+
for k in ("id", "status", "name", "role"):
|
|
323
|
+
assert k in row, f"missing field: {k}"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@pytest.mark.asyncio
|
|
327
|
+
async def test_contract_cache_stats_stats_fields(monkeypatch):
|
|
328
|
+
"""CA-002:GET /api/cache/stats.stats 含 hits / misses / fp_invalidations / stale_fallback_reads。"""
|
|
329
|
+
import httpx
|
|
330
|
+
_stub_watcher(monkeypatch)
|
|
331
|
+
from main import app
|
|
332
|
+
|
|
333
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
334
|
+
r = await c.get("/api/cache/stats")
|
|
335
|
+
assert r.status_code == 200
|
|
336
|
+
stats = r.json()["stats"]
|
|
337
|
+
for k in ("hits", "misses", "fp_invalidations", "stale_fallback_reads"):
|
|
338
|
+
assert k in stats, f"missing stats.{k}"
|
|
339
|
+
assert isinstance(stats[k], int)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.asyncio
|
|
343
|
+
async def test_contract_errors_stats_by_type_fields(monkeypatch):
|
|
344
|
+
"""CA-002:GET /api/errors/stats.framework 含 by_type / by_scope_top / retry_budget_blocks。"""
|
|
345
|
+
import httpx
|
|
346
|
+
_stub_watcher(monkeypatch)
|
|
347
|
+
from main import app
|
|
348
|
+
|
|
349
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
350
|
+
r = await c.get("/api/errors/stats")
|
|
351
|
+
assert r.status_code == 200
|
|
352
|
+
fw = r.json()["framework"]
|
|
353
|
+
assert "by_type" in fw
|
|
354
|
+
assert "by_scope_top" in fw
|
|
355
|
+
assert "retry_budget_blocks" in fw
|
|
356
|
+
assert isinstance(fw["by_type"], dict)
|
|
357
|
+
assert isinstance(fw["by_scope_top"], list)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.asyncio
|
|
361
|
+
async def test_contract_watcher_poll_ticks_counter(monkeypatch):
|
|
362
|
+
"""CA-002:GET /api/health/watcher 含 persisted_snapshot(内含 poll_ticks_counter)。"""
|
|
363
|
+
import httpx
|
|
364
|
+
_stub_watcher(monkeypatch)
|
|
365
|
+
from main import app
|
|
366
|
+
|
|
367
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
368
|
+
r = await c.get("/api/health/watcher")
|
|
369
|
+
assert r.status_code == 200
|
|
370
|
+
snap = r.json().get("persisted_snapshot")
|
|
371
|
+
if snap:
|
|
372
|
+
assert "poll_ticks_counter" in snap, "persisted_snapshot should contain poll_ticks_counter"
|