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,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"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""轻量探针:§3 NFR-P 相关(非严格 SLA,慢机可能波动;CI 可用 -m benchmark 筛选)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
BACKEND = Path(__file__).resolve().parent.parent
|
|
12
|
+
sys.path.insert(0, str(BACKEND))
|
|
13
|
+
|
|
14
|
+
pytestmark = pytest.mark.benchmark
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _stub_watcher(monkeypatch):
|
|
18
|
+
import watchers.file_watcher as fw
|
|
19
|
+
monkeypatch.setattr(fw, "start_file_watcher", lambda loop: None)
|
|
20
|
+
monkeypatch.setattr(fw, "stop_file_watcher", lambda: None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_nfr_p003_record_error_overhead(monkeypatch):
|
|
24
|
+
"""PA-003:record_error 单次调用平均开销 <10ms(NFR-P-003)。"""
|
|
25
|
+
from core.error_handler import record_error
|
|
26
|
+
record_error("unknown", "warmup", "bench:warmup")
|
|
27
|
+
n = 40
|
|
28
|
+
t0 = time.perf_counter()
|
|
29
|
+
for i in range(n):
|
|
30
|
+
record_error("validation-error", f"e{i}", "bench:loop")
|
|
31
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
32
|
+
per_ms = elapsed_ms / n
|
|
33
|
+
assert per_ms < 10.0, f"mean record_error {per_ms:.3f}ms (target <10ms)"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_nfr_p004_parse_large_message_line():
|
|
37
|
+
from utils.data_repair import parse_session_jsonl_line
|
|
38
|
+
|
|
39
|
+
inner = {"role": "user", "content": [{"type": "text", "text": "x" * 40_000}]}
|
|
40
|
+
line = json.dumps({"type": "message", "message": inner}, ensure_ascii=False)
|
|
41
|
+
t0 = time.perf_counter()
|
|
42
|
+
env, msg = parse_session_jsonl_line(line, json_strict=True, auto_repair=False)
|
|
43
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
44
|
+
assert env is not None and msg is not None
|
|
45
|
+
assert elapsed_ms < 800.0, f"parse took {elapsed_ms:.1f}ms"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_nfr_p005_api_response_health(monkeypatch):
|
|
50
|
+
"""PA-005:GET /health 端到端 <200ms。"""
|
|
51
|
+
import httpx
|
|
52
|
+
_stub_watcher(monkeypatch)
|
|
53
|
+
from main import app
|
|
54
|
+
|
|
55
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
56
|
+
t0 = time.perf_counter()
|
|
57
|
+
r = await c.get("/health")
|
|
58
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
59
|
+
assert r.status_code == 200
|
|
60
|
+
assert elapsed_ms < 200.0, f"/health took {elapsed_ms:.1f}ms"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_nfr_p005_api_response_version(monkeypatch):
|
|
65
|
+
"""PA-005:GET /api/version 端到端 <200ms。"""
|
|
66
|
+
import httpx
|
|
67
|
+
_stub_watcher(monkeypatch)
|
|
68
|
+
from main import app
|
|
69
|
+
|
|
70
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
71
|
+
t0 = time.perf_counter()
|
|
72
|
+
r = await c.get("/api/version")
|
|
73
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
74
|
+
assert r.status_code == 200
|
|
75
|
+
assert elapsed_ms < 200.0, f"/api/version took {elapsed_ms:.1f}ms"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_nfr_p005_api_response_cache_stats(monkeypatch):
|
|
80
|
+
"""PA-005:GET /api/cache/stats 端到端 <200ms。"""
|
|
81
|
+
import httpx
|
|
82
|
+
_stub_watcher(monkeypatch)
|
|
83
|
+
from main import app
|
|
84
|
+
|
|
85
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
86
|
+
t0 = time.perf_counter()
|
|
87
|
+
r = await c.get("/api/cache/stats")
|
|
88
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
89
|
+
assert r.status_code == 200
|
|
90
|
+
assert elapsed_ms < 200.0, f"/api/cache/stats took {elapsed_ms:.1f}ms"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_nfr_p005_api_response_errors_stats(monkeypatch):
|
|
95
|
+
"""PA-005:GET /api/errors/stats 端到端 <200ms。"""
|
|
96
|
+
import httpx
|
|
97
|
+
_stub_watcher(monkeypatch)
|
|
98
|
+
from main import app
|
|
99
|
+
|
|
100
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
|
|
101
|
+
t0 = time.perf_counter()
|
|
102
|
+
r = await c.get("/api/errors/stats")
|
|
103
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
104
|
+
assert r.status_code == 200
|
|
105
|
+
assert elapsed_ms < 200.0, f"/api/errors/stats took {elapsed_ms:.1f}ms"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_nfr_p001_resume_watchdog_full_resync(monkeypatch, tmp_path):
|
|
109
|
+
"""PA-001 / NFR-P-001:轮询恢复 watchdog 时的全量同步 <1s。测 _full_resync_cache_and_push 耗时。"""
|
|
110
|
+
import threading
|
|
111
|
+
import watchers.file_watcher as fw
|
|
112
|
+
import data.session_reader as sr
|
|
113
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: tmp_path)
|
|
114
|
+
monkeypatch.setattr(fw, "_watcher_mode", "polling")
|
|
115
|
+
monkeypatch.setattr(fw, "_monitor_stop", threading.Event())
|
|
116
|
+
fake_calls = []
|
|
117
|
+
def fake_on_file_changed(_):
|
|
118
|
+
fake_calls.append(1)
|
|
119
|
+
monkeypatch.setattr(fw, "_on_file_changed", fake_on_file_changed)
|
|
120
|
+
monkeypatch.setattr(fw, "_persist_watcher_state", lambda: None)
|
|
121
|
+
|
|
122
|
+
t0 = time.perf_counter()
|
|
123
|
+
fw._full_resync_cache_and_push()
|
|
124
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
125
|
+
assert len(fake_calls) == 1, "full resync should trigger one _on_file_changed"
|
|
126
|
+
assert elapsed_ms < 1000.0, f"_full_resync_cache_and_push took {elapsed_ms:.1f}ms (target <1000ms)"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_nfr_p001_switch_to_polling(monkeypatch, tmp_path):
|
|
130
|
+
"""PA-001 / NFR-P-001:watchdog 切换到 polling 模式 <1s。测 _switch_to_polling 耗时。"""
|
|
131
|
+
import threading
|
|
132
|
+
import asyncio
|
|
133
|
+
import watchers.file_watcher as fw
|
|
134
|
+
import data.session_reader as sr
|
|
135
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: tmp_path)
|
|
136
|
+
# start in watchdog mode with a stub observer
|
|
137
|
+
monkeypatch.setattr(fw, "_watcher_mode", "watchdog")
|
|
138
|
+
monkeypatch.setattr(fw, "_observer", None) # no real observer
|
|
139
|
+
monkeypatch.setattr(fw, "_stop_watchdog_observer", lambda: None)
|
|
140
|
+
monkeypatch.setattr(fw, "_start_polling_mode", lambda loop: None)
|
|
141
|
+
monkeypatch.setattr(fw, "_persist_watcher_state", lambda: None)
|
|
142
|
+
|
|
143
|
+
fake_loop = asyncio.new_event_loop()
|
|
144
|
+
t0 = time.perf_counter()
|
|
145
|
+
fw._switch_to_polling(fake_loop)
|
|
146
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
147
|
+
assert elapsed_ms < 1000.0, f"_switch_to_polling took {elapsed_ms:.1f}ms (target <1000ms)"
|
|
148
|
+
fake_loop.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_nfr_p002_cache_hit_rate(monkeypatch, tmp_path):
|
|
152
|
+
"""PA-002 / NFR-P-002:缓存命中率 ≥60%。通过 StatusCache 直接验证。"""
|
|
153
|
+
import data.session_reader as sr
|
|
154
|
+
monkeypatch.setattr(sr, "get_openclaw_root", lambda: tmp_path)
|
|
155
|
+
from status.status_cache import StatusCache
|
|
156
|
+
|
|
157
|
+
cache = StatusCache(ttl_ms=60_000)
|
|
158
|
+
cache.get("agent-x") # cold miss
|
|
159
|
+
cache.get("agent-y") # cold miss
|
|
160
|
+
cache.set("agent-a", {"status": "running"})
|
|
161
|
+
cache.get("agent-a") # warm hit
|
|
162
|
+
cache.get("agent-a") # warm hit
|
|
163
|
+
cache.get("agent-a") # warm hit
|
|
164
|
+
cache.get("agent-a") # warm hit
|
|
165
|
+
cache.get("agent-b") # cold miss
|
|
166
|
+
cache.set("agent-b", {"status": "idle"})
|
|
167
|
+
cache.get("agent-b") # warm hit
|
|
168
|
+
cache.get("agent-b") # warm hit
|
|
169
|
+
# 5 hits / (5+3) = 5/8 = 62.5% >= 60%
|
|
170
|
+
|
|
171
|
+
stats = cache.get_stats()
|
|
172
|
+
total = stats["stats"]["hits"] + stats["stats"]["misses"]
|
|
173
|
+
hit_rate = (stats["stats"]["hits"] / total) if total else 0.0
|
|
174
|
+
assert hit_rate >= 0.60, f"hit_rate {hit_rate:.2%} < 60% (hits={stats['stats']['hits']}, misses={stats['stats']['misses']})"
|
|
175
|
+
|
|
176
|
+
|