@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.0
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/GATEWAY.md +1 -0
- package/README.md +39 -0
- package/USAGE.md +8 -0
- package/VERSION +1 -1
- package/bin/benchwarmers.js +5 -3
- package/gateway.py +56 -13
- package/mcp_server.py +266 -0
- package/metrics/client.py +22 -1
- package/metrics/route.py +6 -1
- package/package.json +2 -1
- package/scripts/check.py +2 -0
- package/scripts/test_llm_log.py +81 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +21 -0
- package/scripts/test_serve_edit.py +55 -3
- package/serve.py +127 -25
- package/suite/run.py +4 -2
- package/ui/bench.css +1 -0
- package/ui/bench.js +25 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""An answer stays an answer when the `llm` CLI fails to write its own log.
|
|
2
|
+
|
|
3
|
+
python scripts/test_llm_log.py
|
|
4
|
+
|
|
5
|
+
2026-10-22, panel p20261022-160401-6ca2: seat inkling-small-free printed
|
|
6
|
+
"pong" and was recorded as an error, because `llm` then raised
|
|
7
|
+
"sqlite3.OperationalError: database is locked" writing logs.db, which the
|
|
8
|
+
other seats' `llm` processes held. A fake `llm` here prints an answer and
|
|
9
|
+
fails that way, through the two places the bench runs `llm -m`: the gateway's
|
|
10
|
+
seat call and suite/run.py's ask_once (the perf probe, the suite, repoqa,
|
|
11
|
+
retries, auto-retire). The same lock before any answer, and a stream cut
|
|
12
|
+
mid-answer, stay errors. BENCHWARMERS_HOME and LLM_USER_PATH name temp
|
|
13
|
+
folders before anything is imported.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
24
|
+
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-llmlog-"))
|
|
25
|
+
(TMP / "config").mkdir()
|
|
26
|
+
(TMP / "llm").mkdir()
|
|
27
|
+
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm")
|
|
28
|
+
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
29
|
+
|
|
30
|
+
FAKE = '''import os, sys
|
|
31
|
+
sys.stdin.read()
|
|
32
|
+
mode = os.environ["FAKE_LLM_MODE"]
|
|
33
|
+
if mode in ("locked", "cut"):
|
|
34
|
+
sys.stdout.write("pong\\n" if mode == "locked" else "half an ans")
|
|
35
|
+
sys.stdout.flush()
|
|
36
|
+
if mode == "cut":
|
|
37
|
+
sys.stderr.write("Error: Connection error.\\n")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
if mode != "ok":
|
|
40
|
+
sys.stderr.write("Traceback (most recent call last):\\n"
|
|
41
|
+
" File \\"llm/models.py\\", line 1, in log_to_db\\n"
|
|
42
|
+
"sqlite3.OperationalError: database is locked\\n")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
sys.stdout.write("pong\\n")
|
|
45
|
+
'''
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fake_llm() -> str:
|
|
49
|
+
"""A path that runs like an `llm` executable on Windows, macOS and Linux."""
|
|
50
|
+
script = TMP / "fake_llm.py"
|
|
51
|
+
script.write_text(FAKE, encoding="utf-8")
|
|
52
|
+
if os.name == "nt":
|
|
53
|
+
shim = TMP / "llm.cmd"
|
|
54
|
+
shim.write_text(f'@"{sys.executable}" "{script}" %*\r\n@exit /b %ERRORLEVEL%\r\n', encoding="utf-8")
|
|
55
|
+
else:
|
|
56
|
+
shim = TMP / "llm"
|
|
57
|
+
shim.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n', encoding="utf-8")
|
|
58
|
+
shim.chmod(0o755)
|
|
59
|
+
return str(shim)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
64
|
+
import gateway
|
|
65
|
+
import run
|
|
66
|
+
binary = fake_llm()
|
|
67
|
+
for mode, answered in (("ok", True), ("locked", True), ("locked-early", False), ("cut", False)):
|
|
68
|
+
os.environ["FAKE_LLM_MODE"] = mode
|
|
69
|
+
answer, error = gateway._ask_llm([binary, "-m", "m1"], "ping")
|
|
70
|
+
assert (answer, error == "") == (("pong", True) if answered else (answer, False)), (mode, answer, error)
|
|
71
|
+
body, _ = run.ask_once(binary, "m1", "ping", 60)
|
|
72
|
+
kind = (run.outcome(body, min_length=1) or ("answered", ""))[0]
|
|
73
|
+
assert (kind == "answered") == answered and (body.strip() == "pong") == answered, (mode, kind, body)
|
|
74
|
+
if mode == "locked-early":
|
|
75
|
+
assert "database is locked" in error and "database is locked" in body, (error, body)
|
|
76
|
+
print("ok")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(main())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The MCP server (mcp_server.py) as a client starts it: a subprocess on stdio, against a stub server.
|
|
2
|
+
|
|
3
|
+
python scripts/test_mcp.py
|
|
4
|
+
|
|
5
|
+
initialize and its protocol version negotiation, ping, tools/list, a call of
|
|
6
|
+
each tool (what reaches the server, gateway key included, and what comes
|
|
7
|
+
back), a refusal passed through as a tool error, a ping answered while a
|
|
8
|
+
panel is still running, protocol errors, and the error when no Benchwarmers
|
|
9
|
+
is running. Nothing but protocol messages may reach stdout.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import pathlib
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
|
|
24
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
25
|
+
SEEN: list[tuple[str, str, dict, str]] = [] # (method, path, body, Authorization)
|
|
26
|
+
|
|
27
|
+
MODELS = {"object": "list", "data": [
|
|
28
|
+
{"id": "bench/core", "description": "second opinions", "tags": ["general"], "members": ["m1", "m2"],
|
|
29
|
+
"explorers": 2, "shape": "", "reply": "all", "merger": "",
|
|
30
|
+
"together": {"context_window": 128000, "capabilities": ["tools"], "connections": ["x"], "limits": {}}},
|
|
31
|
+
{"id": "bench/core:merged", "description": "second opinions", "reply": "merged"}]}
|
|
32
|
+
SEATS = [{"call": "p1.member1", "seat": "member", "alias": "m1", "status": "ok", "seconds": 1.0,
|
|
33
|
+
"answer": "answer one", "error": ""},
|
|
34
|
+
{"call": "p1.explorer", "seat": "explorer", "alias": "x1", "status": "empty", "seconds": 2.0,
|
|
35
|
+
"answer": "", "error": ""}]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Stub(BaseHTTPRequestHandler):
|
|
39
|
+
def do_GET(self): # noqa: N802
|
|
40
|
+
SEEN.append(("GET", self.path, {}, self.headers.get("Authorization", "")))
|
|
41
|
+
self._send(200, MODELS)
|
|
42
|
+
|
|
43
|
+
def do_POST(self): # noqa: N802
|
|
44
|
+
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
45
|
+
SEEN.append(("POST", self.path, body, self.headers.get("Authorization", "")))
|
|
46
|
+
if self.path == "/bench/verdict":
|
|
47
|
+
return self._send(200, {"ok": True, "recorded": {"call": body["call"]}})
|
|
48
|
+
if body["model"] == "bench/nope":
|
|
49
|
+
return self._send(404, {"error": {"message": "no collection nope; choose one of bench/core"}})
|
|
50
|
+
time.sleep(1) # a panel takes a while
|
|
51
|
+
merged = (body.get("bench") or {}).get("reply") == "merged"
|
|
52
|
+
self._send(200, {"choices": [{"message": {"role": "assistant", "content": "merged answer" if merged
|
|
53
|
+
else "## member — m1\nanswer one"}}],
|
|
54
|
+
"bench": {"panel": "p1", "collection": body["model"], "shape": "general",
|
|
55
|
+
"seats": SEATS, "reply": "merged" if merged else "all"}})
|
|
56
|
+
|
|
57
|
+
def _send(self, status, data):
|
|
58
|
+
raw = json.dumps(data).encode()
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(raw)
|
|
64
|
+
|
|
65
|
+
def log_message(self, *_a):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Client:
|
|
70
|
+
def __init__(self, url: str, key: str = ""):
|
|
71
|
+
env = {**os.environ, "BENCHWARMERS_URL": url, "BENCHWARMERS_KEY": key, "BENCHWARMERS_TIMEOUT": "30"}
|
|
72
|
+
self.p = subprocess.Popen([sys.executable, str(ROOT / "mcp_server.py")], stdin=subprocess.PIPE,
|
|
73
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
|
74
|
+
self.next = 0
|
|
75
|
+
|
|
76
|
+
def send(self, msg) -> None:
|
|
77
|
+
self.p.stdin.write((msg if isinstance(msg, str) else json.dumps(msg)).encode() + b"\n")
|
|
78
|
+
self.p.stdin.flush()
|
|
79
|
+
|
|
80
|
+
def read(self) -> dict:
|
|
81
|
+
line = self.p.stdout.readline()
|
|
82
|
+
assert line, "the server closed stdout: " + self.p.stderr.read().decode()
|
|
83
|
+
return json.loads(line) # every stdout line must be one JSON-RPC message
|
|
84
|
+
|
|
85
|
+
def request(self, method: str, params: dict | None = None) -> dict:
|
|
86
|
+
self.next += 1
|
|
87
|
+
self.send({"jsonrpc": "2.0", "id": self.next, "method": method, **({"params": params} if params else {})})
|
|
88
|
+
reply = self.read()
|
|
89
|
+
assert reply["id"] == self.next and reply["jsonrpc"] == "2.0", reply
|
|
90
|
+
return reply
|
|
91
|
+
|
|
92
|
+
def tool(self, name: str, **arguments) -> dict:
|
|
93
|
+
return self.request("tools/call", {"name": name, "arguments": arguments})["result"]
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self.p.stdin.close()
|
|
97
|
+
self.p.wait(timeout=10)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> int:
|
|
101
|
+
stub = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
|
|
102
|
+
threading.Thread(target=stub.serve_forever, daemon=True).start()
|
|
103
|
+
url = f"http://127.0.0.1:{stub.server_port}"
|
|
104
|
+
c = Client(url + "/v1", key="bwk_test") # a pasted /v1 is fine
|
|
105
|
+
|
|
106
|
+
init = c.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
107
|
+
"clientInfo": {"name": "test", "version": "0"}})["result"]
|
|
108
|
+
assert init["protocolVersion"] == "2025-06-18", init
|
|
109
|
+
assert init["capabilities"] == {"tools": {"listChanged": False}}, init
|
|
110
|
+
assert init["serverInfo"]["name"] == "benchwarmers" and "verdict" in init["instructions"], init
|
|
111
|
+
c.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # no answer to a notification
|
|
112
|
+
assert c.request("ping")["result"] == {}
|
|
113
|
+
|
|
114
|
+
tools = {t["name"]: t for t in c.request("tools/list")["result"]["tools"]}
|
|
115
|
+
assert set(tools) == {"list_collections", "ask", "verdict"}, tools
|
|
116
|
+
assert tools["ask"]["inputSchema"]["required"] == ["collection", "prompt"]
|
|
117
|
+
|
|
118
|
+
r = c.tool("list_collections")
|
|
119
|
+
assert not r["isError"], r
|
|
120
|
+
rows = json.loads(r["content"][0]["text"])
|
|
121
|
+
assert [x["name"] for x in rows] == ["bench/core"], rows # the :merged twin is folded in
|
|
122
|
+
assert rows[0]["context_window"] == 128000 and rows[0]["members"] == ["m1", "m2"], rows
|
|
123
|
+
assert SEEN[-1][:2] == ("GET", "/v1/models") and SEEN[-1][3] == "Bearer bwk_test", SEEN[-1]
|
|
124
|
+
|
|
125
|
+
# A ping sent while the panel runs is answered first.
|
|
126
|
+
c.send({"jsonrpc": "2.0", "id": "a", "method": "tools/call",
|
|
127
|
+
"params": {"name": "ask", "arguments": {"collection": "core", "prompt": "hi", "system": "be brief",
|
|
128
|
+
"with": ["x1"]}}})
|
|
129
|
+
c.send({"jsonrpc": "2.0", "id": "p", "method": "ping"})
|
|
130
|
+
first, second = c.read(), c.read()
|
|
131
|
+
assert (first["id"], second["id"]) == ("p", "a"), (first, second)
|
|
132
|
+
r = second["result"]
|
|
133
|
+
assert not r["isError"] and "answer one" in r["content"][0]["text"], r
|
|
134
|
+
seats = json.loads(r["content"][1]["text"].split("\n", 1)[1])["seats"]
|
|
135
|
+
assert [s["call"] for s in seats] == ["p1.member1", "p1.explorer"] and "answer" not in seats[0], seats
|
|
136
|
+
method, path, body, _ = SEEN[-1]
|
|
137
|
+
assert (method, path) == ("POST", "/v1/chat/completions"), SEEN[-1]
|
|
138
|
+
assert body["model"] == "bench/core" and body["with"] == ["x1"] and "bench" not in body, body
|
|
139
|
+
assert body["messages"] == [{"role": "system", "content": "be brief"}, {"role": "user", "content": "hi"}]
|
|
140
|
+
|
|
141
|
+
r = c.tool("ask", collection="bench/core", prompt="hi", merged=True)
|
|
142
|
+
assert SEEN[-1][2]["bench"] == {"reply": "merged"}, SEEN[-1]
|
|
143
|
+
assert r["content"][0]["text"] == "merged answer" and "answer one" in r["content"][1]["text"], r
|
|
144
|
+
|
|
145
|
+
r = c.tool("ask", collection="nope", prompt="hi") # the server's refusal, passed on
|
|
146
|
+
assert r["isError"] and "(404)" in r["content"][0]["text"] and "choose one" in r["content"][0]["text"], r
|
|
147
|
+
r = c.tool("ask", collection="core")
|
|
148
|
+
assert r["isError"] and "prompt" in r["content"][0]["text"], r
|
|
149
|
+
|
|
150
|
+
r = c.tool("verdict", call="p1.member1", useful=True, note="checked", real=["a true claim"], false=["x"])
|
|
151
|
+
assert not r["isError"] and "1 real, 1 false" in r["content"][0]["text"], r
|
|
152
|
+
assert SEEN[-1][1:3] == ("/bench/verdict", {"call": "p1.member1", "useful": True, "note": "checked",
|
|
153
|
+
"real": ["a true claim"], "false": ["x"]}), SEEN[-1]
|
|
154
|
+
assert c.tool("verdict", call="p1.member1")["isError"] # useful is required
|
|
155
|
+
|
|
156
|
+
assert c.request("tools/call", {"name": "nope"})["error"]["code"] == -32602
|
|
157
|
+
assert c.request("resources/list")["error"]["code"] == -32601
|
|
158
|
+
c.send("{not json")
|
|
159
|
+
assert c.read()["error"]["code"] == -32700
|
|
160
|
+
c.close()
|
|
161
|
+
|
|
162
|
+
# An unknown protocol version gets the newest; no server running is a clear tool error.
|
|
163
|
+
with socket.socket() as s:
|
|
164
|
+
s.bind(("127.0.0.1", 0))
|
|
165
|
+
dead = s.getsockname()[1]
|
|
166
|
+
c = Client(f"http://127.0.0.1:{dead}")
|
|
167
|
+
init = c.request("initialize", {"protocolVersion": "1999-01-01", "capabilities": {}})["result"]
|
|
168
|
+
assert init["protocolVersion"] == "2025-11-25", init
|
|
169
|
+
r = c.tool("list_collections")
|
|
170
|
+
assert r["isError"] and "not running" in r["content"][0]["text"], r
|
|
171
|
+
c.close()
|
|
172
|
+
stub.shutdown()
|
|
173
|
+
print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
package/scripts/test_merged.py
CHANGED
|
@@ -181,6 +181,27 @@ def main() -> int:
|
|
|
181
181
|
assert serve.save_collection("chat", ["m1"], "", "", [], "merged", "mg")["ok"]
|
|
182
182
|
saved = json.loads((TMP / "config" / "collections.json").read_text(encoding="utf-8"))["chat"]
|
|
183
183
|
assert (saved["reply"], saved["merger"]) == ("merged", "mg"), saved
|
|
184
|
+
|
|
185
|
+
# 9. A retired model a collection still names (member, `with`, merger) is
|
|
186
|
+
# not called: skipped with why, and the merge goes to a merger chosen by verdicts.
|
|
187
|
+
write("retired.json", {"gone": {"served": "gone", "at": "2026-10-20T05:00:00+02:00", "why": "withdrawn"}})
|
|
188
|
+
collection(members=["m1", "gone"], merger="gone")
|
|
189
|
+
ASKED.clear()
|
|
190
|
+
r, b, content = ask("bench/core:merged", **{"with": ["gone"]})
|
|
191
|
+
assert [s["alias"] for s in b["seats"]] == ["m1"] and not any(q["model"] == "gone" for q in ASKED), b
|
|
192
|
+
assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "gone"), ("merger", "gone")}, b
|
|
193
|
+
assert all("retired from the bench on 2026-10-20" in k["why"] for k in b["skipped"]), b["skipped"]
|
|
194
|
+
assert b["reply"] == "merged" and b["merger"]["alias"] != "gone", b
|
|
195
|
+
# Asked for every answer, the reply says who was not asked; `with` a retired model is skipped too.
|
|
196
|
+
collection(members=["m1"])
|
|
197
|
+
r, b, content = ask("bench/core", **{"with": ["gone"]})
|
|
198
|
+
assert b["skipped"] == [{"seat": "with", "alias": "gone", "why": b["skipped"][0]["why"]}], b
|
|
199
|
+
assert "Not asked: gone (retired from the bench" in content and not any(q["model"] == "gone" for q in ASKED)
|
|
200
|
+
# Skipped before the max_calls cap (2 here), so the member after it still sits.
|
|
201
|
+
collection(members=["gone", "m1", "m2"])
|
|
202
|
+
r, b, content = ask("bench/core")
|
|
203
|
+
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and [k["alias"] for k in b["skipped"]] == ["gone"], b
|
|
204
|
+
(TMP / "config" / "retired.json").unlink()
|
|
184
205
|
finally:
|
|
185
206
|
upstream.shutdown()
|
|
186
207
|
gw.shutdown()
|
|
@@ -59,7 +59,12 @@ def main() -> int:
|
|
|
59
59
|
shutil.copy(DATA / "config" / "endpoints.json", cfg / "endpoints.json")
|
|
60
60
|
serve.endpoints.FILE = cfg / "endpoints.json"
|
|
61
61
|
serve.endpoints.YAML = serve.YAML_PATH
|
|
62
|
+
# Collections: removing a model takes it out of them, and Restore puts it back.
|
|
63
|
+
serve.bench_collections.FILE = cfg / "collections.json"
|
|
64
|
+
serve.bench_collections.SETTINGS_FILE = cfg / "gateway.json"
|
|
62
65
|
real = {p: p.read_bytes() for p in (DATA / "metrics" / "alerts.json", DATA / "config" / "endpoints.json")}
|
|
66
|
+
real_collections = (DATA / "config" / "collections.json").read_bytes() \
|
|
67
|
+
if (DATA / "config" / "collections.json").exists() else None
|
|
63
68
|
|
|
64
69
|
d = serve.model_directory()
|
|
65
70
|
some = sorted(d)[0]
|
|
@@ -130,6 +135,13 @@ def main() -> int:
|
|
|
130
135
|
r = serve.remove_model("no-such" if "unknown" in label else victim, **kw)
|
|
131
136
|
ok = not r["ok"] and not r["changed"]
|
|
132
137
|
print(("ok " if ok else "FAIL ") + label); failures += 0 if ok else 1
|
|
138
|
+
# In three collections: a member of two, the merger of one; a fourth without it.
|
|
139
|
+
colls = {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
|
|
140
|
+
"member": {"members": [victim, other], "merger": ""},
|
|
141
|
+
"doomed": {"members": [victim], "merger": ""},
|
|
142
|
+
"without": {"members": [other], "merger": other}}
|
|
143
|
+
(cfg / "collections.json").write_text(json.dumps(colls), encoding="utf-8")
|
|
144
|
+
logged = len(serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines())
|
|
133
145
|
before = len(serve.registered())
|
|
134
146
|
r = serve.remove_model(victim, victim, "retired by provider on 2026-09-15")
|
|
135
147
|
d3 = serve.model_directory()
|
|
@@ -142,8 +154,30 @@ def main() -> int:
|
|
|
142
154
|
and served not in serve.registered())
|
|
143
155
|
print(("ok " if ok else "FAIL ") + f"removed {victim}; registry {before} -> {len(serve.registered())}")
|
|
144
156
|
failures += 0 if ok else 1
|
|
157
|
+
after = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
|
|
158
|
+
log = [json.loads(x) for x in serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()[logged:]]
|
|
159
|
+
ok = (after == {"with-it": {"members": [other], "merger": "", "reply": "merged"},
|
|
160
|
+
"member": {"members": [other], "merger": ""},
|
|
161
|
+
"doomed": {"members": [], "merger": ""},
|
|
162
|
+
"without": colls["without"]}
|
|
163
|
+
and retired[victim]["collections"] == {"with-it": ["members", "merger"], "member": ["members"],
|
|
164
|
+
"doomed": ["members"]}
|
|
165
|
+
and "config/collections.json" in r["changed"]
|
|
166
|
+
and sorted(e["field"] for e in log if e["field"].startswith("collection "))
|
|
167
|
+
== ["collection doomed", "collection member", "collection with-it"])
|
|
168
|
+
print(("ok " if ok else "FAIL ") + "removed from its collections, recorded and logged"
|
|
169
|
+
+ ("" if ok else f" -> {after} {retired[victim]} {r['changed']} {log}")); failures += 0 if ok else 1
|
|
145
170
|
expect_refused("retired name never reused", alias=other, new_alias=victim, new_role=None)
|
|
146
|
-
#
|
|
171
|
+
# The collection dialog's member search matches the served name too, so the view carries it.
|
|
172
|
+
view = serve.collections_view()
|
|
173
|
+
ok = (set(view["served"]) == set(view["models"]) and victim not in view["served"]
|
|
174
|
+
and view["served"].get(other) == d3[other]["served"])
|
|
175
|
+
print(("ok " if ok else "FAIL ") + "collection view: served name for every member choice"); failures += 0 if ok else 1
|
|
176
|
+
# restore: same alias back, out of retired.json, role and ad hoc kept, and
|
|
177
|
+
# back into the collections that still exist (one deleted, one changed since)
|
|
178
|
+
serve.delete_collection("doomed")
|
|
179
|
+
serve.bench_collections.FILE.write_text(json.dumps({**json.loads(serve.bench_collections.FILE.read_text(
|
|
180
|
+
encoding="utf-8")), "member": {"members": [other, "someone-new"], "merger": ""}}), encoding="utf-8")
|
|
147
181
|
r = serve.restore_model(victim)
|
|
148
182
|
retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
|
|
149
183
|
roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))
|
|
@@ -151,6 +185,22 @@ def main() -> int:
|
|
|
151
185
|
and roles["models"].get(victim) == new_role and victim in roles["ad_hoc"])
|
|
152
186
|
print(("ok " if ok else "FAIL ") + f"restored {victim}" + ("" if ok else f" -> {r}"))
|
|
153
187
|
failures += 0 if ok else 1
|
|
188
|
+
back = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
|
|
189
|
+
ok = (back == {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
|
|
190
|
+
"member": {"members": [other, "someone-new", victim], "merger": ""},
|
|
191
|
+
"without": colls["without"]}
|
|
192
|
+
and "config/collections.json" in r["changed"])
|
|
193
|
+
print(("ok " if ok else "FAIL ") + "restored into the collections that still exist"
|
|
194
|
+
+ ("" if ok else f" -> {back} {r['changed']}")); failures += 0 if ok else 1
|
|
195
|
+
# One that was in no collection: collections.json untouched and not reported.
|
|
196
|
+
(cfg / "collections.json").write_text(json.dumps({"without": colls["without"]}), encoding="utf-8")
|
|
197
|
+
r = serve.remove_model(victim, victim, "retired for the no-collection check")
|
|
198
|
+
ok = (r["ok"] and "config/collections.json" not in r["changed"]
|
|
199
|
+
and "collections" not in json.loads((cfg / "retired.json").read_text(encoding="utf-8"))[victim])
|
|
200
|
+
r2 = serve.restore_model(victim)
|
|
201
|
+
ok = ok and r2["ok"] and "config/collections.json" not in r2["changed"]
|
|
202
|
+
print(("ok " if ok else "FAIL ") + "in no collection: collections.json not touched"
|
|
203
|
+
+ ("" if ok else f" -> {r} {r2}")); failures += 0 if ok else 1
|
|
154
204
|
ok = not serve.restore_model(victim)["ok"]
|
|
155
205
|
print(("ok " if ok else "FAIL ") + "restore refused when not retired"); failures += 0 if ok else 1
|
|
156
206
|
serve.remove_model(victim, victim, "retired again for the onboarding checks")
|
|
@@ -374,8 +424,10 @@ def main() -> int:
|
|
|
374
424
|
real_alerts = DATA / "metrics" / "alerts.json"
|
|
375
425
|
victim_keys = {k for k in json.loads(real[real_alerts] or b"{}") if k.endswith(f":{served}")}
|
|
376
426
|
ok = (real[DATA / "config" / "endpoints.json"] == (DATA / "config" / "endpoints.json").read_bytes()
|
|
377
|
-
and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}"))
|
|
378
|
-
|
|
427
|
+
and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}"))
|
|
428
|
+
and real_collections == ((DATA / "config" / "collections.json").read_bytes()
|
|
429
|
+
if (DATA / "config" / "collections.json").exists() else None))
|
|
430
|
+
print(("ok " if ok else "FAIL ") + "the real alerts, endpoints and collections were not touched")
|
|
379
431
|
failures += 0 if ok else 1
|
|
380
432
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
381
433
|
print("\nall rules hold" if not failures else f"\n{failures} FAILED")
|