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,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
+