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
|
@@ -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())
|