switchroom 0.19.18 → 0.19.22
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/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Direct coverage of ``HindsightClient.document_exists`` (#3599 review R3-B2).
|
|
2
|
+
|
|
3
|
+
Every drain test patches ``drain_pending._document_state``, so before this
|
|
4
|
+
file the tri-state itself — the thing the docstring calls "load-bearing and
|
|
5
|
+
must not be collapsed to a bool" — had ZERO executed lines and was asserted
|
|
6
|
+
only in prose. ``return False if e.code == 404 else None`` could be mutated
|
|
7
|
+
to a bare ``return True`` and both suites stayed green.
|
|
8
|
+
|
|
9
|
+
These run against a REAL local HTTP server rather than a mocked ``urllib``:
|
|
10
|
+
the branch under test is precisely the exception taxonomy urllib raises
|
|
11
|
+
(``HTTPError`` with a code vs ``URLError``/``socket.timeout``), and a mock
|
|
12
|
+
that hand-raises those proves the handler, not that urllib produces them.
|
|
13
|
+
|
|
14
|
+
The tri-state contract:
|
|
15
|
+
* ``True`` — 200, the document is there;
|
|
16
|
+
* ``False`` — 404, and ONLY 404. It is the sole answer that lets a caller
|
|
17
|
+
re-POST (reconcile) or keep an entry queued for phase 2;
|
|
18
|
+
* ``None`` — unknown: any 5xx, any transport error, any timeout.
|
|
19
|
+
Collapsing this to ``True`` retires the last on-disk copy of a turn on
|
|
20
|
+
a flaky GET (#3244 silent loss); collapsing it to ``False`` re-POSTs an
|
|
21
|
+
already-durable document at full LLM extraction cost.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import http.server
|
|
25
|
+
import os
|
|
26
|
+
import socket
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import unittest
|
|
30
|
+
import unittest.mock
|
|
31
|
+
|
|
32
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
33
|
+
if SCRIPTS_DIR not in sys.path:
|
|
34
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
35
|
+
|
|
36
|
+
from lib.client import HindsightClient # noqa: E402
|
|
37
|
+
|
|
38
|
+
BANK = "bank-a"
|
|
39
|
+
DOC = "sess-1-rdoc-1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
43
|
+
#: set per-server instance; a callable (handler) -> None, or an int status
|
|
44
|
+
behaviour = 200
|
|
45
|
+
|
|
46
|
+
def do_GET(self): # noqa: N802 — BaseHTTPRequestHandler API
|
|
47
|
+
b = self.server.behaviour
|
|
48
|
+
if callable(b):
|
|
49
|
+
b(self)
|
|
50
|
+
return
|
|
51
|
+
self.send_response(b)
|
|
52
|
+
self.send_header("Content-Type", "application/json")
|
|
53
|
+
body = b'{"id": "%s"}' % DOC.encode() if b == 200 else b"{}"
|
|
54
|
+
self.send_header("Content-Length", str(len(body)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def log_message(self, *_a): # silence the stderr access log
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Server(http.server.HTTPServer):
|
|
63
|
+
behaviour = 200
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DocumentExistsTriStateTest(unittest.TestCase):
|
|
67
|
+
"""200 → True, 404 → False, everything else → None."""
|
|
68
|
+
|
|
69
|
+
def _serve(self, behaviour):
|
|
70
|
+
srv = _Server(("127.0.0.1", 0), _Handler)
|
|
71
|
+
srv.behaviour = behaviour
|
|
72
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
73
|
+
t.start()
|
|
74
|
+
self.addCleanup(t.join, 5)
|
|
75
|
+
self.addCleanup(srv.server_close)
|
|
76
|
+
self.addCleanup(srv.shutdown)
|
|
77
|
+
return HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}")
|
|
78
|
+
|
|
79
|
+
def test_200_is_true(self):
|
|
80
|
+
c = self._serve(200)
|
|
81
|
+
self.assertIs(c.document_exists(BANK, DOC, timeout=5), True)
|
|
82
|
+
|
|
83
|
+
def test_404_is_false(self):
|
|
84
|
+
"""The ONLY negative. A 404 is the server stating the document is
|
|
85
|
+
absent, which is the one answer that authorises a re-POST."""
|
|
86
|
+
c = self._serve(404)
|
|
87
|
+
self.assertIs(c.document_exists(BANK, DOC, timeout=5), False)
|
|
88
|
+
|
|
89
|
+
def test_500_is_unknown(self):
|
|
90
|
+
c = self._serve(500)
|
|
91
|
+
self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
|
|
92
|
+
|
|
93
|
+
def test_503_is_unknown(self):
|
|
94
|
+
"""The reviewer's live repro: a degraded upstream answering 503 must
|
|
95
|
+
leave the entry queued, not retire it."""
|
|
96
|
+
c = self._serve(503)
|
|
97
|
+
self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
|
|
98
|
+
|
|
99
|
+
def test_429_is_unknown(self):
|
|
100
|
+
"""Not every non-404 error is a 5xx; rate limiting says nothing
|
|
101
|
+
about presence either."""
|
|
102
|
+
c = self._serve(429)
|
|
103
|
+
self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
|
|
104
|
+
|
|
105
|
+
def test_timeout_is_unknown(self):
|
|
106
|
+
"""A hung server: urllib raises ``socket.timeout``/``URLError``,
|
|
107
|
+
which is neither presence nor absence."""
|
|
108
|
+
import time
|
|
109
|
+
|
|
110
|
+
def hang(handler):
|
|
111
|
+
time.sleep(3)
|
|
112
|
+
try:
|
|
113
|
+
handler.send_response(200)
|
|
114
|
+
handler.end_headers()
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
c = self._serve(hang)
|
|
119
|
+
self.assertIsNone(c.document_exists(BANK, DOC, timeout=1))
|
|
120
|
+
|
|
121
|
+
def test_connection_refused_is_unknown(self):
|
|
122
|
+
"""Nothing listening at all — the daemon is down, not the document."""
|
|
123
|
+
s = socket.socket()
|
|
124
|
+
s.bind(("127.0.0.1", 0))
|
|
125
|
+
port = s.getsockname()[1]
|
|
126
|
+
s.close()
|
|
127
|
+
c = HindsightClient(f"http://127.0.0.1:{port}")
|
|
128
|
+
self.assertIsNone(c.document_exists(BANK, DOC, timeout=2))
|
|
129
|
+
|
|
130
|
+
def test_malformed_body_on_200_is_still_true(self):
|
|
131
|
+
"""Presence is decided by the STATUS, not by parsing the body: a
|
|
132
|
+
proxy that mangles the payload must not read as an absence."""
|
|
133
|
+
|
|
134
|
+
def garbage(handler):
|
|
135
|
+
handler.send_response(200)
|
|
136
|
+
handler.send_header("Content-Length", "9")
|
|
137
|
+
handler.end_headers()
|
|
138
|
+
handler.wfile.write(b"not-json!")
|
|
139
|
+
|
|
140
|
+
c = self._serve(garbage)
|
|
141
|
+
self.assertIs(c.document_exists(BANK, DOC, timeout=5), True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DocumentExistsRequestShapeTest(unittest.TestCase):
|
|
145
|
+
"""The URL/method/auth the tri-state is derived from."""
|
|
146
|
+
|
|
147
|
+
def test_gets_the_document_path_with_both_ids_url_encoded(self):
|
|
148
|
+
seen = {}
|
|
149
|
+
|
|
150
|
+
def capture(handler):
|
|
151
|
+
seen["path"] = handler.path
|
|
152
|
+
seen["auth"] = handler.headers.get("Authorization")
|
|
153
|
+
seen["method"] = handler.command
|
|
154
|
+
handler.send_response(200)
|
|
155
|
+
handler.send_header("Content-Length", "2")
|
|
156
|
+
handler.end_headers()
|
|
157
|
+
handler.wfile.write(b"{}")
|
|
158
|
+
|
|
159
|
+
srv = _Server(("127.0.0.1", 0), _Handler)
|
|
160
|
+
srv.behaviour = capture
|
|
161
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
162
|
+
t.start()
|
|
163
|
+
self.addCleanup(t.join, 5)
|
|
164
|
+
self.addCleanup(srv.server_close)
|
|
165
|
+
self.addCleanup(srv.shutdown)
|
|
166
|
+
|
|
167
|
+
c = HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}", "tok")
|
|
168
|
+
self.assertIs(c.document_exists("bank/with slash", "doc id", timeout=5), True)
|
|
169
|
+
self.assertEqual(seen["method"], "GET")
|
|
170
|
+
self.assertEqual(
|
|
171
|
+
seen["path"],
|
|
172
|
+
"/v1/default/banks/bank%2Fwith%20slash/documents/doc%20id",
|
|
173
|
+
"both ids must be fully quoted — an unescaped '/' would GET a "
|
|
174
|
+
"different resource and the answer would be about that one",
|
|
175
|
+
)
|
|
176
|
+
self.assertEqual(seen["auth"], "Bearer tok")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DocumentStateWrapperTest(unittest.TestCase):
|
|
180
|
+
"""``drain_pending._document_state`` — the wrapper every drain test
|
|
181
|
+
patches away, and which was therefore never itself executed.
|
|
182
|
+
|
|
183
|
+
It must preserve the tri-state end to end and swallow every exception
|
|
184
|
+
into ``None``: it is called on the retire path, where a raised
|
|
185
|
+
exception escaping into ``drain()``'s per-entry handler would be
|
|
186
|
+
recorded as a POST failure and age the entry toward ``.dead``.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def setUp(self):
|
|
190
|
+
"""Point the fixture at a LIVE server that answers 200 to anything.
|
|
191
|
+
|
|
192
|
+
The previous fixture used an unreachable ``http://127.0.0.1:1/none``,
|
|
193
|
+
which made ``test_missing_ids_are_unknown_not_absent`` unable to fail
|
|
194
|
+
(#3599 review R4-B1): with the ``if not did or not bank`` guard
|
|
195
|
+
deleted outright, control reached ``document_exists``, the connection
|
|
196
|
+
was refused, and the bare ``except`` returned ``None`` — exactly what
|
|
197
|
+
the test asserts. Both ``or`` → ``and`` AND deleting the guard
|
|
198
|
+
survived the whole suite.
|
|
199
|
+
|
|
200
|
+
A live 200-serving loopback models the real hazard. An entry with an
|
|
201
|
+
empty ``document_id`` quotes to ``""``, so the GET hits the
|
|
202
|
+
COLLECTION endpoint ``/v1/default/banks/<bank>/documents/`` — which a
|
|
203
|
+
real daemon answers **200** — and ``document_exists`` returns
|
|
204
|
+
``True``. ``drain()`` would then retire a turn whose document was
|
|
205
|
+
never confirmed: the #3244 silent-loss path this PR exists to close.
|
|
206
|
+
"""
|
|
207
|
+
self.requests: list[str] = []
|
|
208
|
+
|
|
209
|
+
def count(handler):
|
|
210
|
+
self.requests.append(handler.path)
|
|
211
|
+
body = b'{"id": "whatever"}'
|
|
212
|
+
handler.send_response(200)
|
|
213
|
+
handler.send_header("Content-Type", "application/json")
|
|
214
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
215
|
+
handler.end_headers()
|
|
216
|
+
handler.wfile.write(body)
|
|
217
|
+
|
|
218
|
+
srv = _Server(("127.0.0.1", 0), _Handler)
|
|
219
|
+
srv.behaviour = count
|
|
220
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
221
|
+
t.start()
|
|
222
|
+
self.addCleanup(t.join, 5)
|
|
223
|
+
self.addCleanup(srv.server_close)
|
|
224
|
+
self.addCleanup(srv.shutdown)
|
|
225
|
+
self.api_url = f"http://127.0.0.1:{srv.server_address[1]}"
|
|
226
|
+
|
|
227
|
+
def _entry(self, **kw):
|
|
228
|
+
e = {
|
|
229
|
+
"api_url": self.api_url,
|
|
230
|
+
"api_token": None,
|
|
231
|
+
"bank_id": BANK,
|
|
232
|
+
"document_id": DOC,
|
|
233
|
+
}
|
|
234
|
+
e.update(kw)
|
|
235
|
+
return e
|
|
236
|
+
|
|
237
|
+
def test_forwards_the_client_tri_state_unchanged(self):
|
|
238
|
+
import drain_pending
|
|
239
|
+
|
|
240
|
+
for state in (True, False, None):
|
|
241
|
+
with self.subTest(state=state):
|
|
242
|
+
with unittest.mock.patch.object(
|
|
243
|
+
drain_pending.HindsightClient,
|
|
244
|
+
"document_exists",
|
|
245
|
+
lambda self, b, d, timeout=30: state,
|
|
246
|
+
):
|
|
247
|
+
self.assertIs(
|
|
248
|
+
drain_pending._document_state(self._entry()), state
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def test_missing_ids_are_unknown_not_absent(self):
|
|
252
|
+
"""No document_id / bank_id means we cannot ask. That is unknown —
|
|
253
|
+
returning ``False`` would re-POST, ``True`` would retire blind.
|
|
254
|
+
|
|
255
|
+
Two assertions, and the SECOND is the one with teeth: the guard must
|
|
256
|
+
return ``None`` *without asking*. Against this test's live 200 server
|
|
257
|
+
an unguarded empty ``document_id`` GETs the collection endpoint and
|
|
258
|
+
reads back ``True``, so "answered None" alone cannot tell "guarded
|
|
259
|
+
early" from "asked and got a refusal".
|
|
260
|
+
"""
|
|
261
|
+
import drain_pending
|
|
262
|
+
|
|
263
|
+
for kw in ({"document_id": ""}, {"document_id": None}, {"bank_id": ""},
|
|
264
|
+
{"bank_id": None}):
|
|
265
|
+
with self.subTest(**kw):
|
|
266
|
+
self.requests.clear()
|
|
267
|
+
self.assertIsNone(
|
|
268
|
+
drain_pending._document_state(self._entry(**kw))
|
|
269
|
+
)
|
|
270
|
+
self.assertEqual(
|
|
271
|
+
self.requests,
|
|
272
|
+
[],
|
|
273
|
+
"an entry with no usable ids must not reach the daemon at "
|
|
274
|
+
"all: the collection endpoint answers 200 and would read "
|
|
275
|
+
"back as a confirmed presence",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def test_an_empty_document_id_would_confirm_the_collection_endpoint(self):
|
|
279
|
+
"""Why the guard exists, stated as an executable fact rather than a
|
|
280
|
+
comment: with the ids blank the URL degrades to the collection
|
|
281
|
+
endpoint, and that answers 200. This is the failure the guard
|
|
282
|
+
prevents, demonstrated one layer down at the client.
|
|
283
|
+
"""
|
|
284
|
+
c = HindsightClient(self.api_url)
|
|
285
|
+
self.assertIs(c.document_exists(BANK, "", timeout=5), True)
|
|
286
|
+
self.assertEqual(
|
|
287
|
+
self.requests,
|
|
288
|
+
[f"/v1/default/banks/{BANK}/documents/"],
|
|
289
|
+
"an empty document_id quotes to '' and GETs the COLLECTION",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def test_a_raising_client_is_unknown_not_a_crash(self):
|
|
293
|
+
import drain_pending
|
|
294
|
+
|
|
295
|
+
def boom(self, b, d, timeout=30):
|
|
296
|
+
raise RuntimeError("upstream on fire")
|
|
297
|
+
|
|
298
|
+
with unittest.mock.patch.object(
|
|
299
|
+
drain_pending.HindsightClient, "document_exists", boom
|
|
300
|
+
):
|
|
301
|
+
self.assertIsNone(drain_pending._document_state(self._entry()))
|
|
302
|
+
|
|
303
|
+
def test_a_bad_api_url_is_unknown_not_a_crash(self):
|
|
304
|
+
"""``HindsightClient.__init__`` raises on a non-http scheme; an old
|
|
305
|
+
queue entry with a junk url must not take the drain down."""
|
|
306
|
+
import drain_pending
|
|
307
|
+
|
|
308
|
+
self.assertIsNone(
|
|
309
|
+
drain_pending._document_state(self._entry(api_url="ftp://nope/x"))
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class HealthCheckRetryBoundaryTest(unittest.TestCase):
|
|
314
|
+
"""``client.py``'s ``if attempt < retries`` sleep guard (#3599 R3-L3).
|
|
315
|
+
|
|
316
|
+
Mutating it to ``<=`` survived the whole sweep: it costs one extra
|
|
317
|
+
``HEALTH_CHECK_DELAY`` sleep after the LAST attempt, for a result that
|
|
318
|
+
is already decided. The SessionStart drain gate passes ``retries=1``
|
|
319
|
+
precisely so a hung server costs ~one timeout and no delay at all; the
|
|
320
|
+
mutant makes that path sleep 2s inside a 4s hook budget.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def _client(self, attempts_log, ok_after=None):
|
|
324
|
+
c = HindsightClient("http://127.0.0.1:1/none")
|
|
325
|
+
|
|
326
|
+
def fake_urlopen(req, timeout=None):
|
|
327
|
+
attempts_log.append(timeout)
|
|
328
|
+
if ok_after is not None and len(attempts_log) >= ok_after:
|
|
329
|
+
class _R:
|
|
330
|
+
status = 200
|
|
331
|
+
|
|
332
|
+
def __enter__(self_inner):
|
|
333
|
+
return self_inner
|
|
334
|
+
|
|
335
|
+
def __exit__(self_inner, *a):
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
return _R()
|
|
339
|
+
raise OSError("refused")
|
|
340
|
+
|
|
341
|
+
return c, fake_urlopen
|
|
342
|
+
|
|
343
|
+
def _run(self, retries, ok_after=None):
|
|
344
|
+
attempts, slept = [], []
|
|
345
|
+
c, fake = self._client(attempts, ok_after)
|
|
346
|
+
import time as _t
|
|
347
|
+
|
|
348
|
+
with unittest.mock.patch("urllib.request.urlopen", fake):
|
|
349
|
+
with unittest.mock.patch.object(_t, "sleep", slept.append):
|
|
350
|
+
result = c.health_check(timeout=1, retries=retries)
|
|
351
|
+
return result, attempts, slept
|
|
352
|
+
|
|
353
|
+
def test_a_single_retry_never_sleeps(self):
|
|
354
|
+
"""retries=1 is the hook-budget contract: one shot, no delay."""
|
|
355
|
+
ok, attempts, slept = self._run(retries=1)
|
|
356
|
+
self.assertFalse(ok)
|
|
357
|
+
self.assertEqual(len(attempts), 1)
|
|
358
|
+
self.assertEqual(slept, [], "no sleep after the final attempt")
|
|
359
|
+
|
|
360
|
+
def test_n_attempts_sleep_exactly_n_minus_one_times(self):
|
|
361
|
+
ok, attempts, slept = self._run(retries=3)
|
|
362
|
+
self.assertFalse(ok)
|
|
363
|
+
self.assertEqual(len(attempts), 3)
|
|
364
|
+
self.assertEqual(
|
|
365
|
+
len(slept), 2, "a sleep BETWEEN attempts, never after the last"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def test_success_short_circuits_the_remaining_attempts(self):
|
|
369
|
+
ok, attempts, slept = self._run(retries=3, ok_after=2)
|
|
370
|
+
self.assertTrue(ok)
|
|
371
|
+
self.assertEqual(len(attempts), 2)
|
|
372
|
+
self.assertEqual(len(slept), 1)
|
|
373
|
+
|
|
374
|
+
def test_retries_is_floored_at_one(self):
|
|
375
|
+
ok, attempts, slept = self._run(retries=0)
|
|
376
|
+
self.assertEqual(len(attempts), 1, "0 retries still makes one attempt")
|
|
377
|
+
self.assertEqual(slept, [])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class SessionDocumentIdsPagingTest(unittest.TestCase):
|
|
381
|
+
"""``session_document_ids`` must page until the server runs out.
|
|
382
|
+
|
|
383
|
+
The loop stops on a SHORT page (``len(items) < page``). With ``<=`` it
|
|
384
|
+
stops on a FULL one too, so a session with exactly one page of
|
|
385
|
+
documents reports only that page — and the #3244 recovery caller reads
|
|
386
|
+
a truncated set as "these turns were never retained" and restores
|
|
387
|
+
duplicates. A sweep found this uncovered: no test ever served more
|
|
388
|
+
than one page.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def _serve(self, pages):
|
|
392
|
+
"""Serve ``pages`` (a list of lists of ids) by offset."""
|
|
393
|
+
seen = []
|
|
394
|
+
|
|
395
|
+
def handler(h):
|
|
396
|
+
import urllib.parse as up
|
|
397
|
+
|
|
398
|
+
q = up.parse_qs(up.urlparse(h.path).query)
|
|
399
|
+
offset = int(q.get("offset", ["0"])[0])
|
|
400
|
+
limit = int(q.get("limit", ["200"])[0])
|
|
401
|
+
seen.append((offset, limit))
|
|
402
|
+
idx = offset // limit
|
|
403
|
+
items = pages[idx] if idx < len(pages) else []
|
|
404
|
+
body = (
|
|
405
|
+
'{"items": ['
|
|
406
|
+
+ ", ".join('{"id": "%s"}' % i for i in items)
|
|
407
|
+
+ "]}"
|
|
408
|
+
).encode()
|
|
409
|
+
h.send_response(200)
|
|
410
|
+
h.send_header("Content-Type", "application/json")
|
|
411
|
+
h.send_header("Content-Length", str(len(body)))
|
|
412
|
+
h.end_headers()
|
|
413
|
+
h.wfile.write(body)
|
|
414
|
+
|
|
415
|
+
srv = _Server(("127.0.0.1", 0), _Handler)
|
|
416
|
+
srv.behaviour = handler
|
|
417
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
418
|
+
t.start()
|
|
419
|
+
self.addCleanup(t.join, 5)
|
|
420
|
+
self.addCleanup(srv.server_close)
|
|
421
|
+
self.addCleanup(srv.shutdown)
|
|
422
|
+
return HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}"), seen
|
|
423
|
+
|
|
424
|
+
def test_a_full_page_is_followed_by_another_request(self):
|
|
425
|
+
c, seen = self._serve([["a", "b"], ["c"]])
|
|
426
|
+
got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
|
|
427
|
+
self.assertEqual(got, {"a", "b", "c"}, "a full page must not end the walk")
|
|
428
|
+
self.assertEqual([o for o, _ in seen], [0, 2])
|
|
429
|
+
|
|
430
|
+
def test_a_short_page_ends_the_walk(self):
|
|
431
|
+
c, seen = self._serve([["a"], ["never-fetched"]])
|
|
432
|
+
got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
|
|
433
|
+
self.assertEqual(got, {"a"})
|
|
434
|
+
self.assertEqual(len(seen), 1, "a short page means the server is done")
|
|
435
|
+
|
|
436
|
+
def test_an_exactly_full_last_page_costs_one_empty_confirmation(self):
|
|
437
|
+
"""The walk cannot know a full page was the last one without asking."""
|
|
438
|
+
c, seen = self._serve([["a", "b"], []])
|
|
439
|
+
got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
|
|
440
|
+
self.assertEqual(got, {"a", "b"})
|
|
441
|
+
self.assertEqual(len(seen), 2)
|
|
442
|
+
|
|
443
|
+
def test_max_pages_bounds_the_walk(self):
|
|
444
|
+
c, seen = self._serve([["a", "b"]] * 10)
|
|
445
|
+
c.list_session_document_ids(BANK, "sess-1", page=2, max_pages=3, timeout=5)
|
|
446
|
+
self.assertEqual(len(seen), 3, "max_pages is a hard bound")
|
|
447
|
+
|
|
448
|
+
def test_max_pages_is_floored_at_one(self):
|
|
449
|
+
"""``max(1, max_pages)`` — found by the R4-B1 sibling sweep, which
|
|
450
|
+
looked for guards whose only test reaches the same answer by a
|
|
451
|
+
second path. Every existing test passed ``max_pages >= 3``, so
|
|
452
|
+
``max(1, ...)`` → ``max(0, ...)`` survived: nothing ever asked for
|
|
453
|
+
zero pages. It is not equivalent. ``max_pages=0`` under the mutant
|
|
454
|
+
makes ZERO requests and returns an empty set, and the #3244
|
|
455
|
+
recovery caller reads an empty set as "this session was never
|
|
456
|
+
retained" and restores the whole thing — duplicate restore from a
|
|
457
|
+
config value, with no error anywhere. One page is the floor for the
|
|
458
|
+
same reason ``retries`` is floored in ``health_check``: a bound of
|
|
459
|
+
zero is a typo, not a request to do nothing.
|
|
460
|
+
"""
|
|
461
|
+
c, seen = self._serve([["a"], ["never-fetched"]])
|
|
462
|
+
got = c.list_session_document_ids(
|
|
463
|
+
BANK, "sess-1", page=2, max_pages=0, timeout=5
|
|
464
|
+
)
|
|
465
|
+
self.assertEqual(len(seen), 1, "0 pages still makes one request")
|
|
466
|
+
self.assertEqual(got, {"a"})
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
if __name__ == "__main__":
|
|
470
|
+
unittest.main()
|