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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for drain_pending.drain() (#1071)."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import sys
|
|
@@ -32,10 +33,23 @@ class FakeOk:
|
|
|
32
33
|
return False
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
#: A POST-#3244 content-derived document_id (``retain.slice_document_id``):
|
|
37
|
+
#: ``{session}-r{start_uuid}-{end_uuid}``. The presence-GET reconcile is gated
|
|
38
|
+
#: on this shape — a pre-#3244 bare session id is answered 200 by ANY retain in
|
|
39
|
+
#: that session — so the id shape is load-bearing in every drain fixture here.
|
|
40
|
+
CONTENT_DERIVED_DOC_ID = (
|
|
41
|
+
"11111111-2222-4333-8444-555555555555"
|
|
42
|
+
"-r00000000-0000-4000-8000-000000000001"
|
|
43
|
+
"-00000000-0000-4000-8000-000000000002"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _seed_entry(
|
|
48
|
+
pending_dir: str, document_id: str = CONTENT_DERIVED_DOC_ID, attempt: int = 1
|
|
49
|
+
) -> str:
|
|
36
50
|
os.makedirs(pending_dir, mode=0o700, exist_ok=True)
|
|
37
51
|
ts_ms = int(time.time() * 1000)
|
|
38
|
-
name = f"{ts_ms}-{document_id}.json"
|
|
52
|
+
name = f"{ts_ms}-{document_id[:24]}.json"
|
|
39
53
|
path = os.path.join(pending_dir, name)
|
|
40
54
|
payload = {
|
|
41
55
|
"schema": 1,
|
|
@@ -84,6 +98,15 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
84
98
|
for n in ("drain_pending", "lib.pending"):
|
|
85
99
|
sys.modules.pop(n, None)
|
|
86
100
|
|
|
101
|
+
def _reconciled(self):
|
|
102
|
+
"""Basenames archived under ``pending-reconciled/`` (sibling dir)."""
|
|
103
|
+
import lib.pending as pending
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
return sorted(os.listdir(pending.reconciled_dir()))
|
|
107
|
+
except OSError:
|
|
108
|
+
return []
|
|
109
|
+
|
|
87
110
|
def test_drain_empty_queue_is_noop(self):
|
|
88
111
|
import drain_pending
|
|
89
112
|
|
|
@@ -94,15 +117,82 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
94
117
|
self.assertFalse(summary["stalled"])
|
|
95
118
|
self.assertFalse(summary["budget_exceeded"])
|
|
96
119
|
|
|
97
|
-
def
|
|
120
|
+
def test_drain_reconciles_an_already_durable_entry_without_posting(self):
|
|
121
|
+
"""switchroom #3596: GET before POST, in the SessionStart path too.
|
|
122
|
+
|
|
123
|
+
This test used to assert `drained == 1` against a mock that answered
|
|
124
|
+
200 to EVERYTHING, including the presence GET — i.e. it asserted
|
|
125
|
+
that an already-durable document gets re-POSTed anyway. That is the
|
|
126
|
+
re-post loop: the hook's clamped timeout guarantees the client gives
|
|
127
|
+
up, so the entry survives and is re-posted on every boot forever
|
|
128
|
+
while the memory was never actually lost.
|
|
129
|
+
"""
|
|
98
130
|
path = _seed_entry(self._pending)
|
|
99
131
|
import drain_pending
|
|
100
132
|
|
|
101
|
-
|
|
133
|
+
posts = []
|
|
134
|
+
|
|
135
|
+
def record(req, *a, **kw):
|
|
136
|
+
posts.append(getattr(req, "method", None) or req.get_method())
|
|
137
|
+
return FakeOk()
|
|
138
|
+
|
|
139
|
+
with patch("urllib.request.urlopen", side_effect=record):
|
|
140
|
+
summary = drain_pending.drain({})
|
|
141
|
+
self.assertEqual(summary["reconciled"], 1)
|
|
142
|
+
self.assertEqual(summary["drained"], 0)
|
|
143
|
+
self.assertEqual(summary["retried"], 0)
|
|
144
|
+
self.assertFalse(os.path.exists(path))
|
|
145
|
+
self.assertEqual(self._reconciled(), [os.path.basename(path)])
|
|
146
|
+
self.assertEqual(posts, ["GET"], "no retain POST for a durable document")
|
|
147
|
+
|
|
148
|
+
def test_drain_refuses_to_reconcile_a_pre_3244_bare_session_id(self):
|
|
149
|
+
"""A bare session id's 200 is not evidence about THIS entry.
|
|
150
|
+
|
|
151
|
+
The bank answers 200 for a bare session id after any successful
|
|
152
|
+
retain in that session, so reconciling on presence would retire an
|
|
153
|
+
entry whose own content was never committed.
|
|
154
|
+
"""
|
|
155
|
+
path = _seed_entry(
|
|
156
|
+
self._pending, document_id="d52ae253-2d26-42e5-a86b-9a354cc0ace5"
|
|
157
|
+
)
|
|
158
|
+
import drain_pending
|
|
159
|
+
|
|
160
|
+
methods = []
|
|
161
|
+
|
|
162
|
+
def record(req, *a, **kw):
|
|
163
|
+
methods.append(getattr(req, "method", None) or req.get_method())
|
|
164
|
+
raise urllib.error.URLError("upstream down")
|
|
165
|
+
|
|
166
|
+
with patch("urllib.request.urlopen", side_effect=record):
|
|
102
167
|
summary = drain_pending.drain({})
|
|
168
|
+
|
|
169
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
170
|
+
self.assertEqual(methods, ["POST"], "no free-pass GET for a bare session id")
|
|
171
|
+
self.assertTrue(os.path.exists(path), "entry stays queued")
|
|
172
|
+
self.assertEqual(self._reconciled(), [])
|
|
173
|
+
|
|
174
|
+
def test_drain_success_archives_the_entry(self):
|
|
175
|
+
"""A genuinely absent document is POSTed and the entry RETIRED.
|
|
176
|
+
|
|
177
|
+
Retired means moved into ``pending-reconciled/``, not ``os.remove``d:
|
|
178
|
+
this in-hook path retires on the POST's own commit-before-ack 200
|
|
179
|
+
(``async_processing=False``, no confirming GET) — the daemon's word
|
|
180
|
+
about itself, not an independent read (#3244) — so the removal must
|
|
181
|
+
stay recoverable.
|
|
182
|
+
"""
|
|
183
|
+
path = _seed_entry(self._pending)
|
|
184
|
+
import drain_pending
|
|
185
|
+
|
|
186
|
+
with patch.object(
|
|
187
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
188
|
+
):
|
|
189
|
+
with patch("urllib.request.urlopen", return_value=FakeOk()):
|
|
190
|
+
summary = drain_pending.drain({})
|
|
103
191
|
self.assertEqual(summary["drained"], 1)
|
|
192
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
104
193
|
self.assertEqual(summary["retried"], 0)
|
|
105
194
|
self.assertFalse(os.path.exists(path))
|
|
195
|
+
self.assertEqual(self._reconciled(), [os.path.basename(path)])
|
|
106
196
|
|
|
107
197
|
def test_drain_failure_bumps_attempt_count(self):
|
|
108
198
|
path = _seed_entry(self._pending, attempt=1)
|
|
@@ -123,14 +213,17 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
123
213
|
self.assertEqual(entry["attempt_count"], 2)
|
|
124
214
|
self.assertIn("last_attempt_at", entry)
|
|
125
215
|
|
|
126
|
-
def
|
|
216
|
+
def test_drain_max_attempts_marks_dead_on_a_permanent_failure(self):
|
|
217
|
+
"""Exhausting the budget on a 4xx still retires the entry."""
|
|
127
218
|
from lib.pending import MAX_ATTEMPTS
|
|
128
219
|
|
|
129
220
|
path = _seed_entry(self._pending, attempt=MAX_ATTEMPTS)
|
|
130
221
|
import drain_pending
|
|
131
222
|
|
|
132
223
|
def boom(*a, **kw):
|
|
133
|
-
raise urllib.error.
|
|
224
|
+
raise urllib.error.HTTPError(
|
|
225
|
+
"http://h/v1/x", 400, "Bad Request", {}, io.BytesIO(b"bad payload")
|
|
226
|
+
)
|
|
134
227
|
|
|
135
228
|
with patch("urllib.request.urlopen", side_effect=boom):
|
|
136
229
|
summary = drain_pending.drain({})
|
|
@@ -138,6 +231,28 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
138
231
|
self.assertFalse(os.path.exists(path))
|
|
139
232
|
self.assertTrue(os.path.exists(path + ".dead"))
|
|
140
233
|
|
|
234
|
+
def test_drain_max_attempts_keeps_the_memory_on_a_transient_failure(self):
|
|
235
|
+
"""A dead upstream must never retire a memory, however many attempts.
|
|
236
|
+
|
|
237
|
+
``URLError`` is what an unreachable daemon raises. Before the
|
|
238
|
+
permanence gate this retired the entry at MAX_ATTEMPTS — i.e. an
|
|
239
|
+
outage destroyed memory that was perfectly saveable.
|
|
240
|
+
"""
|
|
241
|
+
from lib.pending import MAX_ATTEMPTS
|
|
242
|
+
|
|
243
|
+
path = _seed_entry(self._pending, attempt=MAX_ATTEMPTS)
|
|
244
|
+
import drain_pending
|
|
245
|
+
|
|
246
|
+
def boom(*a, **kw):
|
|
247
|
+
raise urllib.error.URLError("still down")
|
|
248
|
+
|
|
249
|
+
with patch("urllib.request.urlopen", side_effect=boom):
|
|
250
|
+
summary = drain_pending.drain({})
|
|
251
|
+
self.assertEqual(summary["dead"], 0)
|
|
252
|
+
self.assertEqual(summary["retried"], 1)
|
|
253
|
+
self.assertTrue(os.path.exists(path))
|
|
254
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
255
|
+
|
|
141
256
|
def test_drain_stall_guard_stops_after_threshold(self):
|
|
142
257
|
# Seed 10 entries; with a same-error-class stream, the stall
|
|
143
258
|
# guard should trip at STALL_THRESHOLD (3) and leave the rest.
|
|
@@ -249,11 +364,18 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
249
364
|
|
|
250
365
|
import drain_pending
|
|
251
366
|
|
|
252
|
-
|
|
253
|
-
|
|
367
|
+
# All three documents are genuinely absent, so every entry takes the
|
|
368
|
+
# POST path — otherwise the presence GET would consume call slots
|
|
369
|
+
# and this would be testing the reconcile phase by accident.
|
|
370
|
+
with patch.object(
|
|
371
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
372
|
+
):
|
|
373
|
+
with patch("urllib.request.urlopen", side_effect=maybe_ok):
|
|
374
|
+
summary = drain_pending.drain({})
|
|
254
375
|
|
|
255
376
|
self.assertEqual(summary["drained"], 2)
|
|
256
377
|
self.assertEqual(summary["retried"], 1)
|
|
378
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
257
379
|
|
|
258
380
|
|
|
259
381
|
if __name__ == "__main__":
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Tests for the pending-retains persistent queue (#1071)."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
3
5
|
import json
|
|
4
6
|
import os
|
|
5
7
|
import sys
|
|
@@ -65,17 +67,26 @@ class PendingQueueTest(unittest.TestCase):
|
|
|
65
67
|
self.assertIn("failed_at", entry)
|
|
66
68
|
self.assertEqual(entry["schema"], pending_mod.SCHEMA)
|
|
67
69
|
|
|
68
|
-
def
|
|
70
|
+
def test_enqueue_filename_is_unix_ms_key_uuid(self):
|
|
71
|
+
"""``<unix-ms>-<dupe-key>-<uuid>.json`` (switchroom #3596).
|
|
72
|
+
|
|
73
|
+
The dedupe key moved INTO the name so a duplicate lookup is a
|
|
74
|
+
listing prefix match with zero file reads. The leading millisecond
|
|
75
|
+
timestamp is unchanged, so the lexicographic sort in
|
|
76
|
+
``_list_entries`` is still oldest-first.
|
|
77
|
+
"""
|
|
69
78
|
path = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
|
|
70
79
|
name = os.path.basename(path)
|
|
71
80
|
self.assertTrue(name.endswith(".json"))
|
|
72
81
|
head = name[: -len(".json")]
|
|
73
|
-
ts_part, uuid_part = head.split("-"
|
|
82
|
+
ts_part, key_part, uuid_part = head.split("-")
|
|
74
83
|
self.assertTrue(ts_part.isdigit())
|
|
75
84
|
# Filename ts should be within 10 s of now
|
|
76
85
|
now_ms = int(time.time() * 1000)
|
|
77
86
|
self.assertLess(abs(now_ms - int(ts_part)), 10_000)
|
|
78
87
|
self.assertEqual(len(uuid_part), 12)
|
|
88
|
+
self.assertEqual(len(key_part), 16)
|
|
89
|
+
self.assertRegex(key_part, r"^[0-9a-f]{16}$")
|
|
79
90
|
|
|
80
91
|
def test_enqueue_atomic_no_tmp_left_behind(self):
|
|
81
92
|
pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
|
|
@@ -83,16 +94,30 @@ class PendingQueueTest(unittest.TestCase):
|
|
|
83
94
|
self.assertEqual(len(names), 1)
|
|
84
95
|
self.assertFalse(any(n.endswith(".tmp") for n in names))
|
|
85
96
|
|
|
86
|
-
def
|
|
87
|
-
#
|
|
97
|
+
def test_enqueue_evicts_oldest_when_full_instead_of_refusing(self):
|
|
98
|
+
"""switchroom #3596: a full queue sheds the OLDEST entry.
|
|
99
|
+
|
|
100
|
+
This test previously asserted the opposite -- that ``enqueue()``
|
|
101
|
+
returns ``None`` at ``MAX_ENTRIES`` -- which meant throwing away the
|
|
102
|
+
turn that had just happened, the one most likely to still matter,
|
|
103
|
+
while keeping a queue full of stale ones.
|
|
104
|
+
"""
|
|
88
105
|
os.makedirs(self._dir, mode=0o700)
|
|
106
|
+
oldest = os.path.join(self._dir, f"{0:013d}-aaaaaaaaaaaa.json")
|
|
89
107
|
for i in range(pending_mod.MAX_ENTRIES):
|
|
90
108
|
with open(os.path.join(self._dir, f"{i:013d}-aaaaaaaaaaaa.json"), "w") as f:
|
|
91
109
|
json.dump({"placeholder": True}, f)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
|
|
111
|
+
stderr = io.StringIO()
|
|
112
|
+
with contextlib.redirect_stderr(stderr):
|
|
113
|
+
result = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
|
|
114
|
+
|
|
115
|
+
self.assertIsNotNone(result, "the incoming entry must never be refused")
|
|
116
|
+
self.assertTrue(os.path.exists(result))
|
|
117
|
+
self.assertFalse(os.path.exists(oldest), "the oldest entry was evicted")
|
|
118
|
+
# Cap still honoured: one in, one out.
|
|
95
119
|
self.assertEqual(pending_mod.count(), pending_mod.MAX_ENTRIES)
|
|
120
|
+
self.assertIn("evicted OLDEST", stderr.getvalue())
|
|
96
121
|
|
|
97
122
|
def test_iter_entries_ordered_oldest_first(self):
|
|
98
123
|
p1 = pending_mod.enqueue(self._sample_payload("doc-1"), RuntimeError("e1"))
|