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.
Files changed (58) 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 +37 -11
  9. package/dashboard/api/fortify_routes.py +108 -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 +125 -0
  18. package/dashboard/core/error_handler.py +488 -0
  19. package/dashboard/core/fallback_manager.py +81 -0
  20. package/dashboard/core/logging_config.py +217 -0
  21. package/dashboard/core/safe_api_error.py +76 -0
  22. package/dashboard/core/schemas/__init__.py +16 -0
  23. package/dashboard/core/schemas/base.py +43 -0
  24. package/dashboard/core/schemas/session_schema.py +40 -0
  25. package/dashboard/core/schemas/subagent_schema.py +23 -0
  26. package/dashboard/data/agent_config_manager.py +6 -4
  27. package/dashboard/data/chain_reader.py +16 -12
  28. package/dashboard/data/error_analyzer.py +15 -11
  29. package/dashboard/data/session_reader.py +268 -46
  30. package/dashboard/data/subagent_reader.py +74 -49
  31. package/dashboard/data/timeline_reader.py +35 -49
  32. package/dashboard/main.py +24 -2
  33. package/dashboard/mechanism_reader.py +4 -5
  34. package/dashboard/mechanisms.py +2 -2
  35. package/dashboard/pytest.ini +3 -0
  36. package/dashboard/requirements.txt +5 -0
  37. package/dashboard/status/cache_fp_probe.py +40 -0
  38. package/dashboard/status/status_cache.py +199 -72
  39. package/dashboard/status/status_calculator.py +50 -30
  40. package/dashboard/tests/conftest.py +87 -0
  41. package/dashboard/tests/test_api_contracts.py +372 -0
  42. package/dashboard/tests/test_bench_fortify.py +176 -0
  43. package/dashboard/tests/test_fortify.py +952 -0
  44. package/dashboard/utils/__init__.py +1 -0
  45. package/dashboard/utils/data_repair.py +210 -0
  46. package/dashboard/watchers/file_watcher.py +380 -77
  47. package/frontend-dist/assets/{index-cYIOn3Wq.css → index-BIZ2xHfw.css} +1 -1
  48. package/frontend-dist/assets/{index-DyRXGevD.js → index-Cnr0b02R.js} +1 -1
  49. package/frontend-dist/index.html +2 -2
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
  52. package/dashboard/agents.py +0 -74
  53. package/dashboard/collaboration.py +0 -407
  54. package/dashboard/errors.py +0 -63
  55. package/dashboard/performance.py +0 -474
  56. package/dashboard/session_reader.py +0 -240
  57. package/dashboard/status_calculator.py +0 -121
  58. package/dashboard/subagent_reader.py +0 -232
@@ -0,0 +1,952 @@
1
+ """Tests for TECHDEBT_FORTIFY modules."""
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
+ def _stub_file_watcher_for_testclient(monkeypatch) -> None:
15
+ """避免 TestClient 触发 lifespan 时真实启动监听线程导致用例挂起。"""
16
+ import watchers.file_watcher as fw
17
+
18
+ monkeypatch.setattr(fw, "start_file_watcher", lambda loop: None)
19
+ monkeypatch.setattr(fw, "stop_file_watcher", lambda: None)
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def reset_cache_and_fortify_config():
24
+ from core.config_fortify import refresh_fortify_config_cache
25
+ from core.fallback_manager import reset_fallback_handlers_for_tests
26
+ from status.status_cache import reset_cache_for_tests
27
+
28
+ reset_cache_for_tests()
29
+ reset_fallback_handlers_for_tests()
30
+ refresh_fortify_config_cache()
31
+ yield
32
+ reset_cache_for_tests()
33
+ reset_fallback_handlers_for_tests()
34
+ refresh_fortify_config_cache()
35
+
36
+
37
+ def test_status_cache_hits_misses():
38
+ from status.status_cache import StatusCache
39
+
40
+ c = StatusCache(ttl_ms=60_000, max_size=10, max_memory_mb=50)
41
+ assert c.get("x") is None
42
+ c.set("x", {"status": "idle"})
43
+ assert c.get("x") is not None
44
+ assert c.get("x") is not None
45
+ st = c.get_stats()
46
+ assert st["stats"]["misses"] >= 1
47
+ assert st["stats"]["hits"] >= 1
48
+
49
+
50
+ def test_attempt_line_json_repair_trailing_comma():
51
+ from utils.data_repair import attempt_line_json_repair
52
+
53
+ fixed, log = attempt_line_json_repair('{"a":1,}')
54
+ assert fixed is not None
55
+ assert json.loads(fixed)["a"] == 1
56
+ assert log
57
+
58
+
59
+ def test_parse_session_jsonl_line_message():
60
+ from utils.data_repair import parse_session_jsonl_line
61
+
62
+ line = json.dumps({"type": "message", "message": {"role": "user", "content": []}})
63
+ env, msg = parse_session_jsonl_line(line, json_strict=True, auto_repair=False)
64
+ assert env is not None and msg is not None
65
+ assert msg["role"] == "user"
66
+
67
+
68
+ def test_framework_error_stats():
69
+ from core.error_handler import get_framework_error_stats, record_error
70
+
71
+ record_error("network", "test err", "scope:test")
72
+ s = get_framework_error_stats()
73
+ assert s["total_count"] >= 1
74
+ assert "network" in s["by_type"] or any("network" in k for k in s["by_type"])
75
+
76
+
77
+ def test_calculate_agent_status_io_fallback_uses_stale_cache(monkeypatch):
78
+ """REQ_003-AC-003:重算路径 OSError 时读缓存中的最近状态。"""
79
+ from status import status_calculator as sc
80
+ from status.status_cache import get_cache
81
+
82
+ get_cache().set("aid", {"status": "working"})
83
+
84
+ def boom(*a, **k):
85
+ raise OSError("simulated fs")
86
+
87
+ monkeypatch.setattr(sc, "has_recent_errors", boom)
88
+ assert sc.calculate_agent_status("aid", use_cache=False) == "working"
89
+ assert get_cache().get_stats()["stats"]["stale_fallback_reads"] >= 1
90
+
91
+
92
+ def test_calculate_agent_status_io_fallback_disabled(monkeypatch):
93
+ from core.config_fortify import refresh_fortify_config_cache
94
+ from status import status_calculator as sc
95
+ from status.status_cache import get_cache
96
+
97
+ monkeypatch.setenv("OPENCLAW_FALLBACK_CACHE_ON_IO", "false")
98
+ refresh_fortify_config_cache()
99
+ get_cache().set("aid", {"status": "working"})
100
+
101
+ def boom(*a, **k):
102
+ raise OSError("simulated fs")
103
+
104
+ monkeypatch.setattr(sc, "has_recent_errors", boom)
105
+ assert sc.calculate_agent_status("aid", use_cache=False) == "idle"
106
+
107
+
108
+ def test_fallback_manager_register_overrides_default():
109
+ from core import fallback_manager as fm
110
+
111
+ def always_idle(agent_id=None, **kw):
112
+ return "idle"
113
+
114
+ fm.register_fallback("network", always_idle)
115
+ assert fm.run_fallback("network", agent_id="x") == "idle"
116
+
117
+
118
+ def test_fortify_api_routes(monkeypatch):
119
+ import asyncio
120
+ import httpx
121
+ _stub_file_watcher_for_testclient(monkeypatch)
122
+ from main import app
123
+
124
+ async def _run():
125
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
126
+ r = await c.get("/api/health/watcher")
127
+ assert r.status_code == 200
128
+ body = r.json()
129
+ assert "mode" in body
130
+ assert "status" in body
131
+ assert "switch_count" in body
132
+ assert "error_count" in body
133
+ assert "persisted_snapshot" in body
134
+
135
+ r2 = await c.get("/api/cache/stats")
136
+ assert r2.status_code == 200
137
+ assert "hit_rate" in r2.json()
138
+
139
+ r3 = await c.get("/api/errors/stats")
140
+ assert r3.status_code == 200
141
+ j = r3.json()
142
+ assert "framework" in j
143
+ assert "totalCount" in j or "total_count" in j.get("framework", {})
144
+
145
+ asyncio.run(_run())
146
+
147
+
148
+ def test_data_validate_requires_agent(monkeypatch):
149
+ import asyncio
150
+ import httpx
151
+ _stub_file_watcher_for_testclient(monkeypatch)
152
+ from main import app
153
+
154
+ async def _run():
155
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
156
+ r = await c.get("/api/data/validate")
157
+ assert r.status_code == 422
158
+
159
+ asyncio.run(_run())
160
+
161
+
162
+ def test_get_session_validation_report_integrity_and_policy(monkeypatch, tmp_path):
163
+ import data.session_reader as session_reader
164
+
165
+ aid = "demo"
166
+ sess = tmp_path / "agents" / aid / "sessions"
167
+ sess.mkdir(parents=True)
168
+ line = {
169
+ "type": "message",
170
+ "message": {"role": "user", "content": []},
171
+ }
172
+ (sess / "a.jsonl").write_text(json.dumps(line, ensure_ascii=False) + "\n", encoding="utf-8")
173
+ (sess / "sessions.json").write_text("{}", encoding="utf-8")
174
+ monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
175
+
176
+ rep = session_reader.get_session_validation_report(aid)
177
+ assert rep["validation_passed"] is True
178
+ assert rep["file_integrity"] and rep["file_integrity"].get("sha256")
179
+ assert rep["file_integrity"].get("hash_scope") == "full"
180
+ assert "read_path_policy" in rep
181
+ assert "disk_write_back_enabled" in rep["read_path_policy"]
182
+ assert rep["sessions_index_path"]
183
+
184
+
185
+ def test_get_session_validation_report_specific_jsonl(monkeypatch, tmp_path):
186
+ import data.session_reader as session_reader
187
+
188
+ aid = "x"
189
+ sess = tmp_path / "agents" / aid / "sessions"
190
+ sess.mkdir(parents=True)
191
+ good = {"type": "message", "message": {"role": "user", "content": []}}
192
+ bad_line = "{not json"
193
+ (sess / "old.jsonl").write_text(bad_line + "\n", encoding="utf-8")
194
+ (sess / "new.jsonl").write_text(json.dumps(good, ensure_ascii=False) + "\n", encoding="utf-8")
195
+ monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
196
+
197
+ r_old = session_reader.get_session_validation_report(
198
+ aid, relative_session_file="old.jsonl", include_details=True
199
+ )
200
+ assert r_old["validation_passed"] is False
201
+ assert r_old["repair_report"]["failed_repairs"]
202
+
203
+ r_escape = session_reader.get_session_validation_report(
204
+ aid, relative_session_file="../escape.jsonl"
205
+ )
206
+ assert r_escape["validation_passed"] is False
207
+ assert any(e.get("type") == "invalid_session_file" for e in r_escape["errors"])
208
+
209
+
210
+ def test_data_validate_session_file_query_param(monkeypatch, tmp_path):
211
+ import asyncio
212
+ import httpx
213
+ import data.session_reader as session_reader
214
+ _stub_file_watcher_for_testclient(monkeypatch)
215
+ monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
216
+ from main import app
217
+
218
+ aid = "apiagent"
219
+ sess = tmp_path / "agents" / aid / "sessions"
220
+ sess.mkdir(parents=True)
221
+ line = {"type": "message", "message": {"role": "user", "content": []}}
222
+ (sess / "pick.jsonl").write_text(json.dumps(line, ensure_ascii=False) + "\n", encoding="utf-8")
223
+
224
+ async def _run():
225
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
226
+ r = await c.get(
227
+ "/api/data/validate",
228
+ params={"agent_id": aid, "session_file": "pick.jsonl"},
229
+ )
230
+ assert r.status_code == 200
231
+ body = r.json()
232
+ assert body["validation_passed"] is True
233
+ assert "pick.jsonl" in (body.get("session_file") or "")
234
+
235
+ asyncio.run(_run())
236
+
237
+
238
+ def test_audit_repair_emits_audit_log(caplog):
239
+ import logging
240
+
241
+ from utils.data_repair import audit_repair
242
+
243
+ with caplog.at_level(logging.INFO, logger="openclaw.fortify.audit"):
244
+ audit_repair("unit_test", "orig", "fixed")
245
+ assert any("audit_repair" in r.getMessage() for r in caplog.records)
246
+
247
+
248
+ def test_chain_reader_load_runs_via_subagent_reader(monkeypatch, tmp_path):
249
+ import data.chain_reader as chain_reader
250
+ import data.subagent_reader as subagent_reader
251
+
252
+ (tmp_path / "subagents").mkdir(parents=True)
253
+ (tmp_path / "subagents" / "runs.json").write_text(
254
+ json.dumps(
255
+ {
256
+ "version": 2,
257
+ "runs": {
258
+ "run-z": {
259
+ "childSessionKey": "agent:demo:sk",
260
+ "requesterSessionKey": "agent:parent:sk0",
261
+ }
262
+ },
263
+ },
264
+ ensure_ascii=False,
265
+ ),
266
+ encoding="utf-8",
267
+ )
268
+ monkeypatch.setattr(chain_reader, "get_openclaw_root", lambda: tmp_path)
269
+ monkeypatch.setattr(subagent_reader, "get_openclaw_root", lambda: tmp_path)
270
+ data = chain_reader._load_runs()
271
+ assert data.get("version") == 2
272
+ assert "run-z" in data.get("runs", {})
273
+
274
+
275
+ def test_performance_parse_session_file_uses_fortify_parser(tmp_path):
276
+ """parse_session_file / parse_session_file_with_details 经 parse_session_jsonl_line。"""
277
+ import json
278
+ from pathlib import Path
279
+
280
+ from api.performance import parse_session_file, parse_session_file_with_details
281
+
282
+ line_obj = {
283
+ "type": "message",
284
+ "id": "m1",
285
+ "timestamp": "2026-01-01T12:00:00.000Z",
286
+ "message": {
287
+ "role": "assistant",
288
+ "model": "test-model",
289
+ "usage": {"totalTokens": 42, "input": 20, "output": 22},
290
+ "content": [],
291
+ },
292
+ }
293
+ p = tmp_path / "sess.jsonl"
294
+ p.write_text(json.dumps(line_obj, ensure_ascii=False) + "\n", encoding="utf-8")
295
+
296
+ details = parse_session_file_with_details(Path(p), "agent-x")
297
+ assert len(details) == 1
298
+ assert details[0]["tokens"] == 42
299
+ assert details[0]["model"] == "test-model"
300
+
301
+ msgs = parse_session_file(Path(p), range_hours=0)
302
+ assert len(msgs) == 1
303
+ assert msgs[0]["tokens"] == 42
304
+ assert msgs[0]["is_request"] is True
305
+
306
+
307
+ def test_sessions_index_strict_invalid_returns_zero(monkeypatch, tmp_path):
308
+ """sessions.json 根非 object 时,严格模式下 get_session_updated_at 返回 0。"""
309
+ import data.session_reader as session_reader
310
+ from core.config_fortify import refresh_fortify_config_cache
311
+
312
+ monkeypatch.setenv("OPENCLAW_JSON_STRICT", "true")
313
+ refresh_fortify_config_cache()
314
+
315
+ sess = tmp_path / "agents" / "main" / "sessions"
316
+ sess.mkdir(parents=True)
317
+ (sess / "sessions.json").write_text("[]", encoding="utf-8")
318
+
319
+ monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
320
+
321
+ assert session_reader.get_session_updated_at("main") == 0
322
+
323
+
324
+ def test_load_subagent_runs_injects_run_id_from_dict_key(monkeypatch, tmp_path):
325
+ """OpenClaw runs.json 常以 runId 为键,值内可无 runId 字段。"""
326
+ import data.subagent_reader as subagent_reader
327
+
328
+ (tmp_path / "subagents").mkdir(parents=True)
329
+ (tmp_path / "subagents" / "runs.json").write_text(
330
+ json.dumps(
331
+ {
332
+ "version": 2,
333
+ "runs": {
334
+ "run-abc": {
335
+ "childSessionKey": "agent:demo:sk1",
336
+ "requesterSessionKey": "agent:parent:sk0",
337
+ }
338
+ },
339
+ },
340
+ ensure_ascii=False,
341
+ ),
342
+ encoding="utf-8",
343
+ )
344
+ monkeypatch.setattr(subagent_reader, "get_openclaw_root", lambda: tmp_path)
345
+ runs = subagent_reader.load_subagent_runs()
346
+ assert len(runs) == 1
347
+ assert runs[0].get("runId") == "run-abc"
348
+
349
+
350
+ def test_error_analyzer_parse_session_uses_fortify_parser(tmp_path):
351
+ """parse_session_for_errors 经 parse_session_jsonl_line。"""
352
+ from data.error_analyzer import parse_session_for_errors
353
+
354
+ line = {
355
+ "type": "message",
356
+ "timestamp": "2026-01-01T00:00:00.000Z",
357
+ "message": {
358
+ "role": "assistant",
359
+ "stopReason": "error",
360
+ "errorMessage": "boom",
361
+ "content": [],
362
+ },
363
+ }
364
+ p = tmp_path / "s.jsonl"
365
+ p.write_text(json.dumps(line, ensure_ascii=False) + "\n", encoding="utf-8")
366
+ errs = parse_session_for_errors(p)
367
+ assert any(e.get("stopReason") == "error" for e in errs if "error" not in e)
368
+
369
+
370
+ def test_timeline_parse_session_lines_uses_fortify_parser():
371
+ """_parse_session_lines 经 parse_session_jsonl_line(session + message 分支)。"""
372
+ from data import timeline_reader
373
+
374
+ lines = [
375
+ json.dumps({"type": "session", "timestamp": 1000}),
376
+ json.dumps(
377
+ {
378
+ "type": "message",
379
+ "timestamp": 1001,
380
+ "message": {
381
+ "role": "user",
382
+ "content": [{"type": "text", "text": "hi"}],
383
+ },
384
+ }
385
+ ),
386
+ ]
387
+ steps, started_at, _status = timeline_reader._parse_session_lines(lines, None, None)
388
+ assert started_at == 1000
389
+ assert len(steps) >= 1
390
+ assert any(s.type == timeline_reader.StepType.USER.value for s in steps)
391
+
392
+
393
+ def test_status_cache_double_check_mtime_invalidation(monkeypatch, tmp_path):
394
+ """REQ_002-SPEC-06:mtime 变化导致缓存 miss。"""
395
+ import time
396
+
397
+ import data.config_reader as config_reader
398
+ from core.config_fortify import refresh_fortify_config_cache
399
+ from status.status_cache import StatusCache
400
+
401
+ monkeypatch.setenv("OPENCLAW_CACHE_DOUBLE_CHECK", "true")
402
+ refresh_fortify_config_cache()
403
+ monkeypatch.setattr(config_reader, "get_openclaw_root", lambda: tmp_path)
404
+
405
+ aid = "agent1"
406
+ sess = tmp_path / "agents" / aid / "sessions"
407
+ sess.mkdir(parents=True)
408
+ (sess / "sessions.json").write_text("{}", encoding="utf-8")
409
+ (tmp_path / "subagents").mkdir(parents=True)
410
+ (tmp_path / "subagents" / "runs.json").write_text("{}", encoding="utf-8")
411
+
412
+ c = StatusCache(ttl_ms=600_000, max_size=10, max_memory_mb=50)
413
+ c.set(aid, {"status": "idle"})
414
+ assert c.get(aid) is not None
415
+ time.sleep(0.02)
416
+ (sess / "sessions.json").write_text('{"k": 1}', encoding="utf-8")
417
+ assert c.get(aid) is None
418
+ assert c.get_stats()["stats"]["fp_invalidations"] >= 1
419
+
420
+
421
+ def test_status_cache_double_check_disabled_skips_mtime(monkeypatch, tmp_path):
422
+ import data.config_reader as config_reader
423
+ from core.config_fortify import refresh_fortify_config_cache
424
+ from status.status_cache import StatusCache
425
+
426
+ monkeypatch.setenv("OPENCLAW_CACHE_DOUBLE_CHECK", "false")
427
+ refresh_fortify_config_cache()
428
+ monkeypatch.setattr(config_reader, "get_openclaw_root", lambda: tmp_path)
429
+
430
+ aid = "a2"
431
+ sess = tmp_path / "agents" / aid / "sessions"
432
+ sess.mkdir(parents=True)
433
+ (sess / "sessions.json").write_text("{}", encoding="utf-8")
434
+ (tmp_path / "subagents").mkdir(parents=True)
435
+ (tmp_path / "subagents" / "runs.json").write_text("{}", encoding="utf-8")
436
+
437
+ c = StatusCache(ttl_ms=600_000, max_size=10, max_memory_mb=50)
438
+ c.set(aid, {"status": "idle"})
439
+ (sess / "sessions.json").write_text('{"k": 2}', encoding="utf-8")
440
+ got = c.get(aid)
441
+ assert got is not None and got.get("status") == "idle"
442
+ assert c.get_stats()["stats"]["fp_invalidations"] == 0
443
+
444
+
445
+ def test_status_cache_memory_eviction_boundary():
446
+ """REQ_002-AC-004:估算内存超上限时 LRU 逐出。"""
447
+ from status.status_cache import StatusCache
448
+
449
+ c = StatusCache(ttl_ms=600_000, max_size=200, max_memory_mb=1)
450
+ pad = "x" * 80_000
451
+ for i in range(30):
452
+ c.set(f"agent_{i}", {"status": "idle", "pad": pad})
453
+ st = c.get_stats()
454
+ assert st["stats"]["evictions"] >= 1
455
+ assert st["memory_usage_mb"] <= 1.15
456
+
457
+
458
+ def test_watcher_try_resume_watchdog_success(monkeypatch, tmp_path):
459
+ """REQ_001-AC-004:轮询模式下恢复 watchdog 成功路径(自动化)。"""
460
+ from core.config_fortify import refresh_fortify_config_cache
461
+
462
+ monkeypatch.setenv("OPENCLAW_AGENT_DASHBOARD_DATA", str(tmp_path))
463
+ refresh_fortify_config_cache()
464
+
465
+ import watchers.file_watcher as fw
466
+
467
+ class FakeObs:
468
+ def start(self) -> None:
469
+ pass
470
+
471
+ def stop(self) -> None:
472
+ pass
473
+
474
+ def join(self, timeout: float = 0) -> None:
475
+ pass
476
+
477
+ def is_alive(self) -> bool:
478
+ return True
479
+
480
+ monkeypatch.setattr(fw, "_build_observer", lambda: FakeObs())
481
+ monkeypatch.setattr(fw, "_start_monitor_thread", lambda loop: None)
482
+ monkeypatch.setattr(fw, "_on_file_changed", lambda p=None: None)
483
+
484
+ fw._monitor_stop.clear()
485
+ fw._resume_success_count = 0
486
+ fw._resume_failure_count = 0
487
+ fw._watcher_mode = "polling"
488
+ fw._observer = None
489
+ fw._poll_timer = None
490
+
491
+ try:
492
+ fw._try_resume_watchdog(None)
493
+ assert fw._watcher_mode == "watchdog"
494
+ assert fw._resume_success_count == 1
495
+ assert fw._resume_failure_count == 0
496
+ finally:
497
+ fw.stop_file_watcher()
498
+
499
+
500
+ def test_watcher_try_resume_watchdog_failure_increments_counter(monkeypatch, tmp_path):
501
+ from core.config_fortify import refresh_fortify_config_cache
502
+
503
+ monkeypatch.setenv("OPENCLAW_AGENT_DASHBOARD_DATA", str(tmp_path))
504
+ refresh_fortify_config_cache()
505
+
506
+ import watchers.file_watcher as fw
507
+
508
+ def boom():
509
+ raise RuntimeError("no observer")
510
+
511
+ monkeypatch.setattr(fw, "_build_observer", boom)
512
+
513
+ fw._monitor_stop.clear()
514
+ fw._resume_failure_count = 0
515
+ fw._resume_success_count = 0
516
+ fw._watcher_mode = "polling"
517
+ fw._observer = None
518
+
519
+ fw._try_resume_watchdog(None)
520
+ assert fw._watcher_mode == "polling"
521
+ assert fw._resume_failure_count == 1
522
+ assert fw._resume_success_count == 0
523
+
524
+
525
+ def test_watcher_health_error_count_tracks_record_error():
526
+ """REQ_001-AC-005:health.error_count 与 fortify record_error(watcher 相关 scope)对齐。"""
527
+ from core.error_handler import record_error
528
+
529
+ import watchers.file_watcher as fw
530
+
531
+ base = fw.get_watcher_health()["error_count"]
532
+ record_error("io-error", "watcher test", "file_watcher")
533
+ assert fw.get_watcher_health()["error_count"] == base + 1
534
+
535
+
536
+ def test_watcher_persists_state_json(monkeypatch, tmp_path):
537
+ """REQ_001-SPEC-05:磁盘轻量快照。"""
538
+ from core.config_fortify import refresh_fortify_config_cache
539
+
540
+ monkeypatch.setenv("OPENCLAW_AGENT_DASHBOARD_DATA", str(tmp_path))
541
+ refresh_fortify_config_cache()
542
+
543
+ import watchers.file_watcher as fw
544
+
545
+ fw._set_mode("polling")
546
+ p = tmp_path / "watcher_state.json"
547
+ assert p.is_file()
548
+ data = json.loads(p.read_text(encoding="utf-8"))
549
+ assert data.get("mode") == "polling"
550
+
551
+
552
+ def test_classify_exception_permission_and_decode():
553
+ from core.error_handler import classify_exception
554
+
555
+ assert classify_exception(PermissionError("denied")) == "permission-error"
556
+ assert classify_exception(FileNotFoundError("x")) == "io-error"
557
+ assert classify_exception(UnicodeDecodeError("utf-8", b"", 0, 1, "x")) == "parsing-error"
558
+
559
+
560
+ def test_error_handler_exponential_backoff(monkeypatch):
561
+ from core.error_handler import ErrorHandler
562
+
563
+ delays = []
564
+ monkeypatch.setattr("core.error_handler.time.sleep", lambda d: delays.append(d))
565
+ h = ErrorHandler(max_retry=2, base_delay=1.0, enable_fallback=False)
566
+ n = {"v": 0}
567
+
568
+ def fn():
569
+ n["v"] += 1
570
+ if n["v"] < 3:
571
+ raise OSError("fail")
572
+ return "ok"
573
+
574
+ assert h.run_with_retry(fn, operation="t", error_type="io-error") == "ok"
575
+ assert delays == [1.0, 2.0]
576
+
577
+
578
+ def test_framework_error_stats_totals_consistent():
579
+ from core.error_handler import get_framework_error_stats, record_error
580
+
581
+ record_error("network", "a", "scope:stats_a")
582
+ record_error("timeout", "b", "scope:stats_b")
583
+ s = get_framework_error_stats()
584
+ assert s["totals_consistent"] is True
585
+ assert s["sum_by_type"] == s["total_count"]
586
+ assert isinstance(s["by_scope_top"], list)
587
+
588
+
589
+ def test_record_error_includes_exc_metadata():
590
+ from core.error_handler import get_framework_error_stats, record_error
591
+
592
+ record_error("unknown", "x", "meta:test", exc=ValueError("bad"))
593
+ le = get_framework_error_stats()["last_error"]
594
+ assert le.get("exc_type") == "ValueError"
595
+ assert le.get("exc_module") == "builtins"
596
+
597
+
598
+ def test_CA004_app_bootstrap(monkeypatch):
599
+ """CA-004:默认配置下 FastAPI 应用可挂载、文档与版本接口可访问。"""
600
+ import asyncio
601
+ import httpx
602
+ _stub_file_watcher_for_testclient(monkeypatch)
603
+ from main import app
604
+
605
+ async def _run():
606
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
607
+ assert (await c.get("/docs")).status_code == 200
608
+ assert (await c.get("/api/version")).status_code == 200
609
+
610
+ asyncio.run(_run())
611
+
612
+
613
+ def test_input_safety_rejects_traversal():
614
+ """NFR-S-002:路径型参数拒绝 .. / 斜杠等。"""
615
+ from fastapi import HTTPException
616
+
617
+ from api.input_safety import (
618
+ require_safe_agent_id,
619
+ require_safe_run_or_chain_id,
620
+ require_safe_session_file_segment,
621
+ require_safe_session_key,
622
+ )
623
+
624
+ with pytest.raises(HTTPException):
625
+ require_safe_agent_id("a/../b")
626
+ with pytest.raises(HTTPException):
627
+ require_safe_agent_id("x/y")
628
+ with pytest.raises(HTTPException):
629
+ require_safe_session_key("k/../z")
630
+ with pytest.raises(HTTPException):
631
+ require_safe_session_file_segment("..\\x.jsonl")
632
+ with pytest.raises(HTTPException):
633
+ require_safe_run_or_chain_id("../run", name="run_id")
634
+
635
+
636
+ def test_error_analysis_classify_accepts_json_body(monkeypatch):
637
+ """error-analysis/classify 使用 JSON body,并限制 message 长度。"""
638
+ import asyncio
639
+ import httpx
640
+ _stub_file_watcher_for_testclient(monkeypatch)
641
+ from main import app
642
+
643
+ async def _run():
644
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
645
+ r = await c.post("/api/error-analysis/classify", json={"message": "rate limit 429"})
646
+ assert r.status_code == 200
647
+ body = r.json()
648
+ assert "errorType" in body
649
+ r2 = await c.post("/api/error-analysis/classify", json={"message": "x" * 20_000})
650
+ assert r2.status_code == 422
651
+
652
+ asyncio.run(_run())
653
+
654
+
655
+ def test_retry_budget_limits_run_with_retry(monkeypatch):
656
+ """RISK-005:60s 窗口内同一 operation 重试次数受 OPENCLAW_RETRY_BUDGET_PER_MINUTE 约束。"""
657
+ from core.config_fortify import refresh_fortify_config_cache
658
+ from core.error_handler import ErrorHandler, get_framework_error_stats
659
+
660
+ monkeypatch.setenv("OPENCLAW_RETRY_BUDGET_PER_MINUTE", "2")
661
+ refresh_fortify_config_cache()
662
+ before = get_framework_error_stats().get("retry_budget_blocks", 0)
663
+ h = ErrorHandler(max_retry=5, base_delay=0.01, enable_fallback=False)
664
+ n = {"i": 0}
665
+
666
+ def fn():
667
+ n["i"] += 1
668
+ raise OSError("fail")
669
+
670
+ with pytest.raises(OSError):
671
+ h.run_with_retry(fn, operation="budget_test_op", error_type="io-error")
672
+ assert n["i"] == 3
673
+ assert get_framework_error_stats().get("retry_budget_blocks", 0) >= before
674
+
675
+
676
+ def test_invalidate_stale_fp_entries_on_mtime_change(monkeypatch, tmp_path):
677
+ """RISK-004:后台双验证剔除与 mtime 变化一致。"""
678
+ import time
679
+
680
+ import data.config_reader as cr
681
+ from core.config_fortify import refresh_fortify_config_cache
682
+ from status.status_cache import StatusCache
683
+
684
+ monkeypatch.setenv("OPENCLAW_CACHE_DOUBLE_CHECK", "true")
685
+ refresh_fortify_config_cache()
686
+ monkeypatch.setattr(cr, "get_openclaw_root", lambda: tmp_path)
687
+ sess = tmp_path / "agents" / "ag" / "sessions"
688
+ sess.mkdir(parents=True)
689
+ (sess / "sessions.json").write_text("{}", encoding="utf-8")
690
+ (tmp_path / "subagents").mkdir(parents=True)
691
+ (tmp_path / "subagents" / "runs.json").write_text("{}", encoding="utf-8")
692
+
693
+ c = StatusCache(ttl_ms=600_000, max_size=10, max_memory_mb=50)
694
+ c.set("ag", {"status": "idle"})
695
+ assert c.get("ag") is not None
696
+ time.sleep(0.03)
697
+ (sess / "sessions.json").write_text('{"k": 1}', encoding="utf-8")
698
+ assert c.invalidate_stale_fp_entries() >= 1
699
+ assert c.get("ag") is None
700
+
701
+
702
+ def test_sanitize_client_error_text_redacts_secrets():
703
+ """NFR-S-001:API 文案脱敏 sk- / Bearer / 路径等。"""
704
+ from core.safe_api_error import sanitize_client_error_text
705
+
706
+ s = sanitize_client_error_text("fail sk-abcdefghijklmnopqrstuvwx trailing")
707
+ assert "sk-abcdefghij" not in s
708
+ assert "REDACTED" in s
709
+ s2 = sanitize_client_error_text("Authorization Bearer abcdefghijklmnop")
710
+ assert "REDACTED" in s2
711
+
712
+
713
+ def test_get_framework_error_stats_for_client_redacts_last_error(monkeypatch):
714
+ from core.config_fortify import refresh_fortify_config_cache
715
+ from core.error_handler import get_framework_error_stats_for_client, record_error
716
+
717
+ monkeypatch.setenv("OPENCLAW_API_ERROR_SANITIZE", "true")
718
+ refresh_fortify_config_cache()
719
+ record_error("unknown", "x sk-abcdefghijklmnopqrstuvwx", "scope:test_redact_client")
720
+ out = get_framework_error_stats_for_client()
721
+ le = out.get("last_error") or {}
722
+ detail = str(le.get("detail", ""))
723
+ assert "sk-abc" not in detail
724
+
725
+
726
+ def test_risk003_malformed_jsonl_lines_handled():
727
+ """RISK-003:畸形行不抛未捕获异常。"""
728
+ from utils.data_repair import parse_session_jsonl_line
729
+
730
+ samples = [
731
+ "",
732
+ "{",
733
+ "{not json",
734
+ '{"type":"message"}',
735
+ '{"type":"message","message":null}',
736
+ '{"type":"message","message":{"role":"user","content":[]}}',
737
+ ]
738
+ for raw in samples:
739
+ env, msg = parse_session_jsonl_line(raw, auto_repair=False, json_strict=True)
740
+ assert env is None or isinstance(env, dict)
741
+ assert msg is None or isinstance(msg, dict)
742
+
743
+
744
+ # === NFR-R Reliability Metrics Tests ===
745
+
746
+ def test_reliability_metrics_fallback_tracking():
747
+ """NFR-R-005:优雅降级率追踪。"""
748
+ from core.error_handler import (
749
+ get_reliability_metrics,
750
+ record_fallback_attempt,
751
+ reset_reliability_metrics_for_tests,
752
+ )
753
+
754
+ reset_reliability_metrics_for_tests()
755
+ record_fallback_attempt(success=True)
756
+ record_fallback_attempt(success=True)
757
+ record_fallback_attempt(success=False)
758
+
759
+ metrics = get_reliability_metrics()
760
+ assert metrics["graceful_degradation_attempts"] == 3
761
+ assert metrics["graceful_degradation_successes"] == 2
762
+ assert metrics["graceful_degradation_rate"] == pytest.approx(2 / 3, rel=0.01)
763
+ assert metrics["graceful_degradation_percentage"] == pytest.approx(66.67, rel=0.5)
764
+
765
+
766
+ def test_reliability_metrics_error_recovery_time():
767
+ """NFR-R-003:错误恢复时间追踪。"""
768
+ from core.error_handler import (
769
+ get_reliability_metrics,
770
+ record_error_recovery,
771
+ reset_reliability_metrics_for_tests,
772
+ )
773
+
774
+ reset_reliability_metrics_for_tests()
775
+ record_error_recovery(3.5)
776
+ record_error_recovery(4.0)
777
+ record_error_recovery(2.5)
778
+
779
+ metrics = get_reliability_metrics()
780
+ assert metrics["error_recovery_count"] == 3
781
+ assert metrics["avg_error_recovery_seconds"] == pytest.approx(3.333, rel=0.1)
782
+ assert metrics["p95_error_recovery_seconds"] >= 2.5
783
+
784
+
785
+ def test_reliability_metrics_watcher_availability():
786
+ """NFR-R-002:监听可用性追踪。"""
787
+ import time
788
+
789
+ from core.error_handler import (
790
+ get_reliability_metrics,
791
+ record_watcher_failure,
792
+ record_watcher_recovery,
793
+ reset_reliability_metrics_for_tests,
794
+ )
795
+
796
+ reset_reliability_metrics_for_tests()
797
+
798
+ # Simulate watcher failure and recovery
799
+ record_watcher_failure()
800
+ time.sleep(0.05)
801
+ record_watcher_recovery()
802
+
803
+ metrics = get_reliability_metrics()
804
+ assert metrics["watcher_uptime_seconds"] >= 0
805
+ assert metrics["watcher_downtime_seconds"] >= 0.04
806
+ assert metrics["watcher_availability_rate"] >= 0
807
+ assert metrics["watcher_uptime_percentage"] >= 0
808
+
809
+
810
+ def test_reliability_metrics_integrated_in_framework_stats():
811
+ """NFR-R:reliability 字段集成到 get_framework_error_stats。"""
812
+ from core.error_handler import (
813
+ get_framework_error_stats,
814
+ reset_reliability_metrics_for_tests,
815
+ )
816
+
817
+ reset_reliability_metrics_for_tests()
818
+ stats = get_framework_error_stats()
819
+ assert "reliability" in stats
820
+ rel = stats["reliability"]
821
+ assert "watcher_availability_rate" in rel
822
+ assert "avg_error_recovery_seconds" in rel
823
+ assert "graceful_degradation_rate" in rel
824
+
825
+
826
+ def test_reliability_api_endpoint(monkeypatch):
827
+ """NFR-R:/api/errors/reliability 接口可用。"""
828
+ import asyncio
829
+ import httpx
830
+
831
+ _stub_file_watcher_for_testclient(monkeypatch)
832
+ from main import app
833
+
834
+ async def _run():
835
+ async with httpx.AsyncClient(
836
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
837
+ ) as c:
838
+ r = await c.get("/api/errors/reliability")
839
+ assert r.status_code == 200
840
+ body = r.json()
841
+ assert "watcher_availability_rate" in body
842
+ assert "avg_error_recovery_seconds" in body
843
+ assert "graceful_degradation_rate" in body
844
+
845
+ asyncio.run(_run())
846
+
847
+
848
+ def test_watcher_health_includes_reliability(monkeypatch):
849
+ """NFR-R:/api/health/watcher 包含 reliability 字段。"""
850
+ import asyncio
851
+ import httpx
852
+
853
+ _stub_file_watcher_for_testclient(monkeypatch)
854
+ from main import app
855
+
856
+ async def _run():
857
+ async with httpx.AsyncClient(
858
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
859
+ ) as c:
860
+ r = await c.get("/api/health/watcher")
861
+ assert r.status_code == 200
862
+ body = r.json()
863
+ assert "reliability" in body
864
+ assert "watcher_availability_rate" in body["reliability"]
865
+
866
+ asyncio.run(_run())
867
+
868
+
869
+ # === NFR-S-003: Logging storage security tests ===
870
+
871
+ def test_logging_config_env_vars():
872
+ """NFR-S-003:日志配置从环境变量读取。"""
873
+ import os
874
+
875
+ from core.config_fortify import get_fortify_config, refresh_fortify_config_cache
876
+
877
+ # Test default values
878
+ os.environ.pop("OPENCLAW_LOG_RETENTION_DAYS", None)
879
+ os.environ.pop("OPENCLAW_LOG_MAX_SIZE_MB", None)
880
+ os.environ.pop("OPENCLAW_LOG_BACKUP_COUNT", None)
881
+ os.environ.pop("OPENCLAW_LOG_COMPRESSION", None)
882
+ refresh_fortify_config_cache()
883
+ cfg = get_fortify_config()
884
+ assert cfg.log_retention_days == 30
885
+ assert cfg.log_max_size_mb == 100
886
+ assert cfg.log_backup_count == 5
887
+ assert cfg.log_compression is True
888
+
889
+ # Test custom values
890
+ os.environ["OPENCLAW_LOG_RETENTION_DAYS"] = "7"
891
+ os.environ["OPENCLAW_LOG_MAX_SIZE_MB"] = "50"
892
+ os.environ["OPENCLAW_LOG_BACKUP_COUNT"] = "10"
893
+ os.environ["OPENCLAW_LOG_COMPRESSION"] = "false"
894
+ refresh_fortify_config_cache()
895
+ cfg = get_fortify_config()
896
+ assert cfg.log_retention_days == 7
897
+ assert cfg.log_max_size_mb == 50
898
+ assert cfg.log_backup_count == 10
899
+ assert cfg.log_compression is False
900
+
901
+ # Cleanup
902
+ os.environ.pop("OPENCLAW_LOG_RETENTION_DAYS", None)
903
+ os.environ.pop("OPENCLAW_LOG_MAX_SIZE_MB", None)
904
+ os.environ.pop("OPENCLAW_LOG_BACKUP_COUNT", None)
905
+ os.environ.pop("OPENCLAW_LOG_COMPRESSION", None)
906
+ refresh_fortify_config_cache()
907
+
908
+
909
+ def test_logging_config_summary(monkeypatch, tmp_path):
910
+ """NFR-S-003:get_logging_config_summary 返回正确信息。"""
911
+ import os
912
+ from core.config_fortify import refresh_fortify_config_cache
913
+
914
+ # Use a temp path for log file
915
+ log_file = tmp_path / "test.log"
916
+ os.environ["OPENCLAW_LOG_FILE_PATH"] = str(log_file)
917
+ refresh_fortify_config_cache()
918
+
919
+ try:
920
+ from core.logging_config import get_logging_config_summary
921
+
922
+ summary = get_logging_config_summary()
923
+ assert summary["log_retention_days"] == 30
924
+ assert summary["log_max_size_mb"] == 100
925
+ assert summary["log_backup_count"] == 5
926
+ assert summary["log_file_path"] == str(log_file)
927
+ assert summary["log_compression"] is True
928
+ finally:
929
+ os.environ.pop("OPENCLAW_LOG_FILE_PATH", None)
930
+ refresh_fortify_config_cache()
931
+
932
+
933
+ def test_logging_api_endpoint(monkeypatch):
934
+ """NFR-S-003:/api/logging/config 接口可用。"""
935
+ import asyncio
936
+ import httpx
937
+
938
+ _stub_file_watcher_for_testclient(monkeypatch)
939
+ from main import app
940
+
941
+ async def _run():
942
+ async with httpx.AsyncClient(
943
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
944
+ ) as c:
945
+ r = await c.get("/api/logging/config")
946
+ assert r.status_code == 200
947
+ body = r.json()
948
+ assert body["status"] == "ok"
949
+ assert "config" in body
950
+ assert "log_retention_days" in body["config"]
951
+
952
+ asyncio.run(_run())