switchroom 0.19.18 → 0.19.19
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 +3 -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 +3392 -1569
- package/dist/host-control/main.js +12209 -11396
- package/dist/vault/approvals/kernel-server.js +60 -7
- package/dist/vault/broker/server.js +206 -76
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- 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 +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -32,10 +32,23 @@ class FakeOk:
|
|
|
32
32
|
return False
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
#: A POST-#3244 content-derived document_id (``retain.slice_document_id``):
|
|
36
|
+
#: ``{session}-r{start_uuid}-{end_uuid}``. The presence-GET reconcile is gated
|
|
37
|
+
#: on this shape — a pre-#3244 bare session id is answered 200 by ANY retain in
|
|
38
|
+
#: that session — so the id shape is load-bearing in every drain fixture here.
|
|
39
|
+
CONTENT_DERIVED_DOC_ID = (
|
|
40
|
+
"11111111-2222-4333-8444-555555555555"
|
|
41
|
+
"-r00000000-0000-4000-8000-000000000001"
|
|
42
|
+
"-00000000-0000-4000-8000-000000000002"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _seed_entry(
|
|
47
|
+
pending_dir: str, document_id: str = CONTENT_DERIVED_DOC_ID, attempt: int = 1
|
|
48
|
+
) -> str:
|
|
36
49
|
os.makedirs(pending_dir, mode=0o700, exist_ok=True)
|
|
37
50
|
ts_ms = int(time.time() * 1000)
|
|
38
|
-
name = f"{ts_ms}-{document_id}.json"
|
|
51
|
+
name = f"{ts_ms}-{document_id[:24]}.json"
|
|
39
52
|
path = os.path.join(pending_dir, name)
|
|
40
53
|
payload = {
|
|
41
54
|
"schema": 1,
|
|
@@ -84,6 +97,15 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
84
97
|
for n in ("drain_pending", "lib.pending"):
|
|
85
98
|
sys.modules.pop(n, None)
|
|
86
99
|
|
|
100
|
+
def _reconciled(self):
|
|
101
|
+
"""Basenames archived under ``pending-reconciled/`` (sibling dir)."""
|
|
102
|
+
import lib.pending as pending
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return sorted(os.listdir(pending.reconciled_dir()))
|
|
106
|
+
except OSError:
|
|
107
|
+
return []
|
|
108
|
+
|
|
87
109
|
def test_drain_empty_queue_is_noop(self):
|
|
88
110
|
import drain_pending
|
|
89
111
|
|
|
@@ -94,15 +116,82 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
94
116
|
self.assertFalse(summary["stalled"])
|
|
95
117
|
self.assertFalse(summary["budget_exceeded"])
|
|
96
118
|
|
|
97
|
-
def
|
|
119
|
+
def test_drain_reconciles_an_already_durable_entry_without_posting(self):
|
|
120
|
+
"""switchroom #3596: GET before POST, in the SessionStart path too.
|
|
121
|
+
|
|
122
|
+
This test used to assert `drained == 1` against a mock that answered
|
|
123
|
+
200 to EVERYTHING, including the presence GET — i.e. it asserted
|
|
124
|
+
that an already-durable document gets re-POSTed anyway. That is the
|
|
125
|
+
re-post loop: the hook's clamped timeout guarantees the client gives
|
|
126
|
+
up, so the entry survives and is re-posted on every boot forever
|
|
127
|
+
while the memory was never actually lost.
|
|
128
|
+
"""
|
|
98
129
|
path = _seed_entry(self._pending)
|
|
99
130
|
import drain_pending
|
|
100
131
|
|
|
101
|
-
|
|
132
|
+
posts = []
|
|
133
|
+
|
|
134
|
+
def record(req, *a, **kw):
|
|
135
|
+
posts.append(getattr(req, "method", None) or req.get_method())
|
|
136
|
+
return FakeOk()
|
|
137
|
+
|
|
138
|
+
with patch("urllib.request.urlopen", side_effect=record):
|
|
139
|
+
summary = drain_pending.drain({})
|
|
140
|
+
self.assertEqual(summary["reconciled"], 1)
|
|
141
|
+
self.assertEqual(summary["drained"], 0)
|
|
142
|
+
self.assertEqual(summary["retried"], 0)
|
|
143
|
+
self.assertFalse(os.path.exists(path))
|
|
144
|
+
self.assertEqual(self._reconciled(), [os.path.basename(path)])
|
|
145
|
+
self.assertEqual(posts, ["GET"], "no retain POST for a durable document")
|
|
146
|
+
|
|
147
|
+
def test_drain_refuses_to_reconcile_a_pre_3244_bare_session_id(self):
|
|
148
|
+
"""A bare session id's 200 is not evidence about THIS entry.
|
|
149
|
+
|
|
150
|
+
The bank answers 200 for a bare session id after any successful
|
|
151
|
+
retain in that session, so reconciling on presence would retire an
|
|
152
|
+
entry whose own content was never committed.
|
|
153
|
+
"""
|
|
154
|
+
path = _seed_entry(
|
|
155
|
+
self._pending, document_id="d52ae253-2d26-42e5-a86b-9a354cc0ace5"
|
|
156
|
+
)
|
|
157
|
+
import drain_pending
|
|
158
|
+
|
|
159
|
+
methods = []
|
|
160
|
+
|
|
161
|
+
def record(req, *a, **kw):
|
|
162
|
+
methods.append(getattr(req, "method", None) or req.get_method())
|
|
163
|
+
raise urllib.error.URLError("upstream down")
|
|
164
|
+
|
|
165
|
+
with patch("urllib.request.urlopen", side_effect=record):
|
|
102
166
|
summary = drain_pending.drain({})
|
|
167
|
+
|
|
168
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
169
|
+
self.assertEqual(methods, ["POST"], "no free-pass GET for a bare session id")
|
|
170
|
+
self.assertTrue(os.path.exists(path), "entry stays queued")
|
|
171
|
+
self.assertEqual(self._reconciled(), [])
|
|
172
|
+
|
|
173
|
+
def test_drain_success_archives_the_entry(self):
|
|
174
|
+
"""A genuinely absent document is POSTed and the entry RETIRED.
|
|
175
|
+
|
|
176
|
+
Retired means moved into ``pending-reconciled/``, not ``os.remove``d:
|
|
177
|
+
this in-hook path retires on the POST's own commit-before-ack 200
|
|
178
|
+
(``async_processing=False``, no confirming GET) — the daemon's word
|
|
179
|
+
about itself, not an independent read (#3244) — so the removal must
|
|
180
|
+
stay recoverable.
|
|
181
|
+
"""
|
|
182
|
+
path = _seed_entry(self._pending)
|
|
183
|
+
import drain_pending
|
|
184
|
+
|
|
185
|
+
with patch.object(
|
|
186
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
187
|
+
):
|
|
188
|
+
with patch("urllib.request.urlopen", return_value=FakeOk()):
|
|
189
|
+
summary = drain_pending.drain({})
|
|
103
190
|
self.assertEqual(summary["drained"], 1)
|
|
191
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
104
192
|
self.assertEqual(summary["retried"], 0)
|
|
105
193
|
self.assertFalse(os.path.exists(path))
|
|
194
|
+
self.assertEqual(self._reconciled(), [os.path.basename(path)])
|
|
106
195
|
|
|
107
196
|
def test_drain_failure_bumps_attempt_count(self):
|
|
108
197
|
path = _seed_entry(self._pending, attempt=1)
|
|
@@ -249,11 +338,18 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
249
338
|
|
|
250
339
|
import drain_pending
|
|
251
340
|
|
|
252
|
-
|
|
253
|
-
|
|
341
|
+
# All three documents are genuinely absent, so every entry takes the
|
|
342
|
+
# POST path — otherwise the presence GET would consume call slots
|
|
343
|
+
# and this would be testing the reconcile phase by accident.
|
|
344
|
+
with patch.object(
|
|
345
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
346
|
+
):
|
|
347
|
+
with patch("urllib.request.urlopen", side_effect=maybe_ok):
|
|
348
|
+
summary = drain_pending.drain({})
|
|
254
349
|
|
|
255
350
|
self.assertEqual(summary["drained"], 2)
|
|
256
351
|
self.assertEqual(summary["retried"], 1)
|
|
352
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
257
353
|
|
|
258
354
|
|
|
259
355
|
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"))
|