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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -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 +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. 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
- def _seed_entry(pending_dir: str, document_id: str = "doc-x", attempt: int = 1) -> str:
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 test_drain_success_deletes_entry(self):
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
- with patch("urllib.request.urlopen", return_value=FakeOk()):
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 test_drain_max_attempts_marks_dead(self):
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.URLError("still down")
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
- with patch("urllib.request.urlopen", side_effect=maybe_ok):
253
- summary = drain_pending.drain({})
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 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"))