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.
Files changed (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. 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
- def _seed_entry(pending_dir: str, document_id: str = "doc-x", attempt: int = 1) -> str:
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 test_drain_success_deletes_entry(self):
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
- with patch("urllib.request.urlopen", return_value=FakeOk()):
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
- with patch("urllib.request.urlopen", side_effect=maybe_ok):
253
- summary = drain_pending.drain({})
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 test_enqueue_filename_is_unix_ms_uuid(self):
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("-", 1)
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 test_enqueue_returns_none_when_full(self):
87
- # Pre-populate with MAX_ENTRIES dummy files.
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
- result = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
93
- self.assertIsNone(result)
94
- # Count unchanged
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"))