switchroom 0.17.5 → 0.17.10

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 (158) hide show
  1. package/dist/agent-scheduler/index.js +39 -5
  2. package/dist/auth-broker/index.js +386 -208
  3. package/dist/cli/notion-write-pretool.mjs +36 -3
  4. package/dist/cli/switchroom.js +1185 -585
  5. package/dist/host-control/main.js +149 -15
  6. package/dist/vault/approvals/kernel-server.js +141 -56
  7. package/dist/vault/broker/server.js +143 -58
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +50 -2
  13. package/telegram-plugin/bridge/bridge.ts +80 -1
  14. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  15. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  16. package/telegram-plugin/consolidation-legibility.ts +279 -0
  17. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  18. package/telegram-plugin/dist/gateway/gateway.js +2597 -645
  19. package/telegram-plugin/dist/server.js +86 -2
  20. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  21. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  22. package/telegram-plugin/gateway/auth-command.ts +1 -1
  23. package/telegram-plugin/gateway/gateway.ts +1414 -119
  24. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  26. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  27. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  28. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  29. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  30. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  31. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  32. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  33. package/telegram-plugin/memory-legibility.ts +217 -0
  34. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  35. package/telegram-plugin/scoped-approval.ts +59 -0
  36. package/telegram-plugin/silent-end.ts +78 -0
  37. package/telegram-plugin/subagent-watcher.ts +60 -6
  38. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  40. package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
  41. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  42. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  43. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  44. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  45. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  46. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  48. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  49. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  50. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  51. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  52. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  53. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  54. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  55. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  56. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  57. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  58. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  59. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  60. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  61. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  62. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  63. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  64. package/telegram-plugin/uat/assertions.ts +88 -4
  65. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  66. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  71. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  72. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  74. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  75. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  76. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  77. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  97. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  98. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  99. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  100. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  101. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  129. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  130. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  131. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  132. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  157. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  158. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,436 @@
1
+ /**
2
+ * activity-card-store.test.ts — outcome-based tests for the mid-turn
3
+ * activity-card restart-survivability fix (Known Gap 1,
4
+ * `reference/rfcs/deterministic-turn-liveness.md`).
5
+ *
6
+ * PRE-FIX PROOF: before this PR, no persistence existed for
7
+ * `CurrentTurn.activityMessageId` at all — `../gateway/activity-card-store.js`
8
+ * did not exist, so `import` itself failed at collection time:
9
+ *
10
+ * Error: Cannot find module '../gateway/activity-card-store.js'
11
+ * (or its corresponding .ts source) imported from
12
+ * telegram-plugin/tests/activity-card-store.test.ts
13
+ *
14
+ * i.e. every test below fails on today's (pre-fix) base — there was no seam
15
+ * to test. Recorded run against `origin/main` (this PR's parent commit)
16
+ * before the module was added:
17
+ *
18
+ * FAIL telegram-plugin/tests/activity-card-store.test.ts
19
+ * [vitest] Cannot find module '../gateway/activity-card-store.js'
20
+ *
21
+ * This file exercises the REAL exported store + boot-reaper functions (not a
22
+ * structural grep), mirroring `status-pin-store.test.ts`'s in-memory fs seam
23
+ * convention, plus a transport-boundary reaper test (mirrors
24
+ * `silent-end-transport.test.ts`'s spy-transport convention) proving the
25
+ * exact outcome contract: (a) card open persists a record; (b) clean close
26
+ * clears it; (c) a boot reaper run against a leftover record performs
27
+ * EXACTLY ONE finalizing edit + one unpin and deletes the record; (d) a
28
+ * second boot performs ZERO edits and ZERO unpins; (e) a corrupt state file
29
+ * never crashes boot and is dropped without an edit.
30
+ */
31
+ import { describe, it, expect } from 'vitest'
32
+ import {
33
+ loadActivityCards,
34
+ persistActivityCards,
35
+ writeActivityCardRecord,
36
+ clearActivityCardRecord,
37
+ runActivityCardBootReaper,
38
+ restartOrphanCardFinalizeText,
39
+ type ActivityCardRecord,
40
+ type ActivityCardStoreFsSeam,
41
+ } from '../gateway/activity-card-store.js'
42
+
43
+ /** In-memory fs seam with an atomic rename — same convention as
44
+ * status-pin-store.test.ts's memFs, so the tmp→rename crash-safety
45
+ * contract is exercised without touching real disk. */
46
+ function memFs(seed: Record<string, string> = {}) {
47
+ const files = new Map<string, string>(Object.entries(seed))
48
+ const calls: string[] = []
49
+ const fs: ActivityCardStoreFsSeam = {
50
+ readFileSync: (p) => {
51
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
52
+ return files.get(p)!
53
+ },
54
+ writeFileSync: (p, d) => {
55
+ calls.push(`write:${p}`)
56
+ files.set(p, d)
57
+ },
58
+ renameSync: (a, b) => {
59
+ calls.push(`rename:${a}->${b}`)
60
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
61
+ files.set(b, files.get(a)!)
62
+ files.delete(a)
63
+ },
64
+ existsSync: (p) => files.has(p),
65
+ }
66
+ return { fs, files, calls }
67
+ }
68
+
69
+ const PATH = '/state/agent/telegram/activity-cards-pending.json'
70
+
71
+ function card(over: Partial<ActivityCardRecord> = {}): ActivityCardRecord {
72
+ return {
73
+ turnKey: 'c:_',
74
+ chatId: '-100123',
75
+ threadId: null,
76
+ activityMessageId: 715,
77
+ startedAt: Date.now() - 60_000,
78
+ pinned: true,
79
+ ...over,
80
+ }
81
+ }
82
+
83
+ describe('activity-card-store — persistence primitives', () => {
84
+ it('loads [] when no file exists (fresh boot)', () => {
85
+ const { fs } = memFs()
86
+ expect(loadActivityCards(PATH, fs)).toEqual([])
87
+ })
88
+
89
+ it('(a) card open → state persisted (round-trips a written record)', () => {
90
+ const { fs } = memFs()
91
+ // Construct ONCE — card() stamps `startedAt: Date.now() - 60_000`, so two
92
+ // separate calls can straddle a millisecond and flake the deep-equal.
93
+ const record = card()
94
+ writeActivityCardRecord(PATH, fs, record)
95
+ const loaded = loadActivityCards(PATH, fs)
96
+ expect(loaded).toHaveLength(1)
97
+ expect(loaded[0]).toEqual(record)
98
+ })
99
+
100
+ it('a fresh OPEN for the same topic replaces (upserts) the stale row, not appends', () => {
101
+ const { fs } = memFs()
102
+ writeActivityCardRecord(PATH, fs, card({ activityMessageId: 715 }))
103
+ writeActivityCardRecord(PATH, fs, card({ activityMessageId: 900 }))
104
+ const loaded = loadActivityCards(PATH, fs)
105
+ expect(loaded).toHaveLength(1)
106
+ expect(loaded[0]!.activityMessageId).toBe(900)
107
+ })
108
+
109
+ it('(b) clean close → state cleared', () => {
110
+ const { fs } = memFs()
111
+ writeActivityCardRecord(PATH, fs, card())
112
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
113
+ clearActivityCardRecord(PATH, fs, 'c:_')
114
+ expect(loadActivityCards(PATH, fs)).toEqual([])
115
+ })
116
+
117
+ it('clearing an absent/mismatched turnKey is a no-op (never throws)', () => {
118
+ const { fs } = memFs()
119
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:other' }))
120
+ clearActivityCardRecord(PATH, fs, 'c:_')
121
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
122
+ })
123
+
124
+ it('writes via tmp + atomic rename (crash-safe)', () => {
125
+ const { fs, calls } = memFs()
126
+ persistActivityCards(PATH, fs, [card()])
127
+ expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
128
+ })
129
+
130
+ it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
131
+ const { fs } = memFs({ [PATH]: '{not json' })
132
+ expect(() => loadActivityCards(PATH, fs)).not.toThrow()
133
+ expect(loadActivityCards(PATH, fs)).toEqual([])
134
+ })
135
+
136
+ it('fail-open: wrong envelope version / malformed shape loads as []', () => {
137
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
138
+ expect(loadActivityCards(PATH, f1)).toEqual([])
139
+ const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
140
+ expect(loadActivityCards(PATH, f2)).toEqual([])
141
+ })
142
+
143
+ it('drops malformed rows but keeps valid ones', () => {
144
+ const { fs } = memFs({
145
+ [PATH]: JSON.stringify({
146
+ v: 1,
147
+ cards: [
148
+ card({ activityMessageId: 715 }),
149
+ { turnKey: '', chatId: 'c', threadId: null, activityMessageId: 1, startedAt: 1 },
150
+ { turnKey: 'k', chatId: 'c', threadId: null, startedAt: 1 }, // missing activityMessageId
151
+ ],
152
+ }),
153
+ })
154
+ const loaded = loadActivityCards(PATH, fs)
155
+ expect(loaded).toHaveLength(1)
156
+ expect(loaded[0]!.activityMessageId).toBe(715)
157
+ })
158
+
159
+ it('persist never throws on a failing fs (durability degrades, live path unaffected)', () => {
160
+ const throwingFs: ActivityCardStoreFsSeam = {
161
+ readFileSync: () => {
162
+ throw new Error('boom')
163
+ },
164
+ writeFileSync: () => {
165
+ throw new Error('disk full')
166
+ },
167
+ renameSync: () => {
168
+ throw new Error('nope')
169
+ },
170
+ existsSync: () => false,
171
+ }
172
+ const logs: string[] = []
173
+ expect(() => persistActivityCards(PATH, throwingFs, [card()], (l) => logs.push(l))).not.toThrow()
174
+ expect(logs.join('')).toContain('persist FAILED')
175
+ })
176
+ })
177
+
178
+ /**
179
+ * Boot reaper — transport-boundary outcome tests (mirrors
180
+ * silent-end-transport.test.ts's spy-transport convention): a spy stands in
181
+ * for `bot.api.editMessageText` / `bot.api.unpinChatMessage`, asserting an
182
+ * actual finalizing edit + unpin land, not a stubbed decision callback.
183
+ */
184
+ describe('runActivityCardBootReaper — transport-boundary outcome tests', () => {
185
+ it('(c) leftover record → exactly one finalizing edit + one unpin, record deleted', async () => {
186
+ const { fs } = memFs()
187
+ const startedAt = Date.now() - 90_000
188
+ writeActivityCardRecord(PATH, fs, card({ startedAt }))
189
+
190
+ const edits: Array<{ chatId: string; messageId: number; text: string }> = []
191
+ const unpins: Array<{ chatId: string; messageId: number }> = []
192
+
193
+ const res = await runActivityCardBootReaper({
194
+ path: PATH,
195
+ fs,
196
+ finalizeCard: async (record) => {
197
+ edits.push({
198
+ chatId: record.chatId,
199
+ messageId: record.activityMessageId,
200
+ text: restartOrphanCardFinalizeText(record.startedAt),
201
+ })
202
+ // A truthy result models a REAL delivered edit (grammy returns the
203
+ // edited Message); a benign-400 would resolve to undefined instead.
204
+ return { message_id: record.activityMessageId }
205
+ },
206
+ unpinCard: async (record) => {
207
+ unpins.push({ chatId: record.chatId, messageId: record.activityMessageId })
208
+ },
209
+ })
210
+
211
+ expect(edits).toHaveLength(1)
212
+ expect(edits[0]!.chatId).toBe('-100123')
213
+ expect(edits[0]!.messageId).toBe(715)
214
+ expect(edits[0]!.text).toContain('Interrupted by a gateway restart')
215
+ expect(edits[0]!.text).toContain('was running')
216
+
217
+ expect(unpins).toHaveLength(1)
218
+ expect(unpins[0]).toEqual({ chatId: '-100123', messageId: 715 })
219
+
220
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 1, total: 1 })
221
+ // Record gone — nothing left for a future boot to re-finalize.
222
+ expect(loadActivityCards(PATH, fs)).toEqual([])
223
+ })
224
+
225
+ it('(d) second boot with an already-empty store → zero edits, zero unpins', async () => {
226
+ const { fs } = memFs()
227
+ writeActivityCardRecord(PATH, fs, card())
228
+
229
+ let edits = 0
230
+ let unpins = 0
231
+ // First boot reaps the one leftover record.
232
+ await runActivityCardBootReaper({
233
+ path: PATH,
234
+ fs,
235
+ finalizeCard: async () => {
236
+ edits++
237
+ return { ok: true }
238
+ },
239
+ unpinCard: async () => {
240
+ unpins++
241
+ },
242
+ })
243
+ expect(edits).toBe(1)
244
+ expect(unpins).toBe(1)
245
+
246
+ // Second boot re-reads the (now empty) file — the idempotency guarantee.
247
+ const res2 = await runActivityCardBootReaper({
248
+ path: PATH,
249
+ fs,
250
+ finalizeCard: async () => {
251
+ edits++
252
+ return { ok: true }
253
+ },
254
+ unpinCard: async () => {
255
+ unpins++
256
+ },
257
+ })
258
+ expect(edits).toBe(1) // unchanged
259
+ expect(unpins).toBe(1) // unchanged
260
+ expect(res2).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 0 })
261
+ })
262
+
263
+ it('a record with pinned:false (or absent) is finalized but never unpinned', async () => {
264
+ const { fs } = memFs()
265
+ writeActivityCardRecord(PATH, fs, card({ pinned: false }))
266
+ let unpins = 0
267
+ const res = await runActivityCardBootReaper({
268
+ path: PATH,
269
+ fs,
270
+ finalizeCard: async () => ({ ok: true }),
271
+ unpinCard: async () => {
272
+ unpins++
273
+ },
274
+ })
275
+ expect(res.finalized).toBe(1)
276
+ expect(unpins).toBe(0)
277
+ })
278
+
279
+ it('no-op on a fresh boot with no persisted cards (zero edits, zero unpins)', async () => {
280
+ const { fs } = memFs()
281
+ let calls = 0
282
+ const res = await runActivityCardBootReaper({
283
+ path: PATH,
284
+ fs,
285
+ finalizeCard: async () => {
286
+ calls++
287
+ return { ok: true }
288
+ },
289
+ unpinCard: async () => {
290
+ calls++
291
+ },
292
+ })
293
+ expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 0 })
294
+ expect(calls).toBe(0)
295
+ })
296
+
297
+ it('a failing finalize edit is non-fatal, still attempts the unpin, and the store is still emptied', async () => {
298
+ const { fs } = memFs()
299
+ writeActivityCardRecord(PATH, fs, card())
300
+ let unpinCalled = false
301
+ const res = await runActivityCardBootReaper({
302
+ path: PATH,
303
+ fs,
304
+ finalizeCard: async () => {
305
+ throw new Error('message to edit not found')
306
+ },
307
+ unpinCard: async () => {
308
+ unpinCalled = true
309
+ },
310
+ })
311
+ expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 1, total: 1 })
312
+ expect(unpinCalled).toBe(true)
313
+ expect(loadActivityCards(PATH, fs)).toEqual([])
314
+ })
315
+
316
+ it('a failing unpin (already unpinned / no rights) is non-fatal and does not re-run the edit', async () => {
317
+ const { fs } = memFs()
318
+ writeActivityCardRecord(PATH, fs, card())
319
+ let editCalls = 0
320
+ const res = await runActivityCardBootReaper({
321
+ path: PATH,
322
+ fs,
323
+ finalizeCard: async () => {
324
+ editCalls++
325
+ return { ok: true }
326
+ },
327
+ unpinCard: async () => {
328
+ throw new Error('message to unpin not found')
329
+ },
330
+ })
331
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 0, total: 1 })
332
+ expect(editCalls).toBe(1)
333
+ expect(loadActivityCards(PATH, fs)).toEqual([])
334
+ })
335
+
336
+ it('reaps multiple leftover records from distinct topics independently', async () => {
337
+ const { fs } = memFs()
338
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:1', chatId: '-100111', activityMessageId: 1 }))
339
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:2', chatId: '-100222', activityMessageId: 2 }))
340
+ const edited: number[] = []
341
+ const unpinned: number[] = []
342
+ const res = await runActivityCardBootReaper({
343
+ path: PATH,
344
+ fs,
345
+ finalizeCard: async (r) => {
346
+ edited.push(r.activityMessageId)
347
+ return { message_id: r.activityMessageId }
348
+ },
349
+ unpinCard: async (r) => {
350
+ unpinned.push(r.activityMessageId)
351
+ },
352
+ })
353
+ expect(res).toEqual({ finalized: 2, vanished: 0, unpinned: 2, total: 2 })
354
+ expect(edited.sort()).toEqual([1, 2])
355
+ expect(unpinned.sort()).toEqual([1, 2])
356
+ expect(loadActivityCards(PATH, fs)).toEqual([])
357
+ })
358
+
359
+ it('idempotency guard: the record is removed from disk BEFORE the finalize/unpin calls run (crash-mid-reap safety)', async () => {
360
+ const { fs } = memFs()
361
+ writeActivityCardRecord(PATH, fs, card())
362
+ let onDiskDuringFinalize: ActivityCardRecord[] = []
363
+ await runActivityCardBootReaper({
364
+ path: PATH,
365
+ fs,
366
+ finalizeCard: async () => {
367
+ onDiskDuringFinalize = loadActivityCards(PATH, fs)
368
+ },
369
+ unpinCard: async () => {},
370
+ })
371
+ // At the moment finalizeCard ran, the record was ALREADY gone from disk —
372
+ // so a crash right here (or a concurrent second boot) can never re-reap it.
373
+ expect(onDiskDuringFinalize).toEqual([])
374
+ })
375
+
376
+ it('a benign-400 (finalizeCard resolves undefined) counts as vanished, not finalized', async () => {
377
+ const { fs } = memFs()
378
+ writeActivityCardRecord(PATH, fs, card({ pinned: false }))
379
+ // robustApiCall swallows "message to edit not found" / "not modified" and
380
+ // resolves to undefined — nothing was delivered, so it must not be counted
381
+ // as a finalize (the pre-fix boot log over-reported the guarantee).
382
+ const res = await runActivityCardBootReaper({
383
+ path: PATH,
384
+ fs,
385
+ finalizeCard: async () => undefined,
386
+ unpinCard: async () => {},
387
+ })
388
+ expect(res).toEqual({ finalized: 0, vanished: 1, unpinned: 0, total: 1 })
389
+ expect(loadActivityCards(PATH, fs)).toEqual([])
390
+ })
391
+
392
+ it('reap-race guard: an id-scoped clear leaves a live turn\'s fresh upsert (same turnKey) intact', async () => {
393
+ const { fs } = memFs()
394
+ // The reaper loads this OLD card (id=715) and finalizes it…
395
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 715 }))
396
+ await runActivityCardBootReaper({
397
+ path: PATH,
398
+ fs,
399
+ finalizeCard: async () => {
400
+ // …but MID-REAP (e.g. during a 429 flood-wait) a fresh live turn opens a
401
+ // NEW card under the same topic key (id=999). Its record must survive.
402
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 999 }))
403
+ return { ok: true }
404
+ },
405
+ unpinCard: async () => {},
406
+ })
407
+ // The id-scoped clear removed only the OLD (715) row; the live 999 row is
408
+ // still protected. A turnKey-only clear would have wiped it.
409
+ const remaining = loadActivityCards(PATH, fs)
410
+ expect(remaining).toHaveLength(1)
411
+ expect(remaining[0]!.activityMessageId).toBe(999)
412
+ })
413
+
414
+ it('clearActivityCardRecord with a mismatched id is a no-op (does not drop a same-turnKey different-id row)', () => {
415
+ const { fs } = memFs()
416
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 999 }))
417
+ clearActivityCardRecord(PATH, fs, 'c:_', 715) // wrong id
418
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
419
+ clearActivityCardRecord(PATH, fs, 'c:_', 999) // matching id
420
+ expect(loadActivityCards(PATH, fs)).toEqual([])
421
+ })
422
+ })
423
+
424
+ describe('restartOrphanCardFinalizeText', () => {
425
+ it('is honest about elapsed time', () => {
426
+ const text = restartOrphanCardFinalizeText(Date.now() - 45_000)
427
+ expect(text).toContain('Interrupted by a gateway restart')
428
+ expect(text).toMatch(/was running \d+s/)
429
+ })
430
+
431
+ it('omits a nonsensical elapsed clause for a degenerate startedAt', () => {
432
+ const text = restartOrphanCardFinalizeText(Date.now() + 10_000) // future — negative elapsed
433
+ expect(text).toContain('Interrupted by a gateway restart')
434
+ expect(text).not.toContain('was running')
435
+ })
436
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Activity-card durability WIRING (deterministic-turn-liveness.md Known Gap 1 +
3
+ * the #2895 review fix). The 20 behavioural tests in activity-card-store.test.ts
4
+ * drive the pure store/reaper in ISOLATION — they prove the routine is correct
5
+ * but say nothing about whether the GATEWAY actually calls it. That is exactly
6
+ * the decision-vs-delivery gap the whole RFC exists to close, applied to its own
7
+ * follow-up: a refactor that drops the `writeActivityCardRecord` call on card
8
+ * open would keep every store test green while silently un-delivering the
9
+ * guarantee.
10
+ *
11
+ * The gateway IIFE can't be instantiated in-process (same constraint as
12
+ * silence-liveness-wiring.test.ts / multitopic-routing-wiring.test.ts), so these
13
+ * are STRUCTURAL assertions that pin the three load-bearing call sites. They
14
+ * COMPLEMENT (never replace) the behavioural store tests — the RFC's "structural
15
+ * grep may not be the SOLE coverage" bar is met because the outcome is proven at
16
+ * the routine level; this only guards the wiring the routine depends on.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readFileSync } from 'node:fs'
20
+ import { resolve } from 'node:path'
21
+
22
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
23
+
24
+ function between(src: string, startMarker: string, endMarker: string): string {
25
+ const after = src.split(startMarker)[1] ?? ''
26
+ return after.split(endMarker)[0] ?? ''
27
+ }
28
+
29
+ describe('activity-card durability wiring', () => {
30
+ it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
31
+ // The open branch runs when activityMessageId transitions null → set.
32
+ const openBranch = between(
33
+ gatewaySrc,
34
+ 'if (turn.activityMessageId == null) {',
35
+ 'turn.activityLastSentRender = target',
36
+ )
37
+ expect(openBranch.length).toBeGreaterThan(100)
38
+ expect(openBranch).toMatch(/writeActivityCardRecord\(/)
39
+ // Persist is gated on the feature flag, not unconditional.
40
+ expect(openBranch).toMatch(/activityCardPersistEnabled/)
41
+ // Pin honesty: the persisted RECORD mirrors the ACTUAL pin decision, never
42
+ // a bare `pinned: true` (the #2895 review fix). Scope the negative check to
43
+ // the writeActivityCardRecord argument block — the reconcileStatusPin call
44
+ // further down legitimately passes `{ pinned: true }` as the desired state.
45
+ const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
46
+ expect(recordBlock).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
47
+ // Strip comment lines (the code comment legitimately quotes the old
48
+ // `pinned: true` shape it's warning against) before checking the CODE.
49
+ const recordCode = recordBlock
50
+ .split('\n')
51
+ .filter((l) => !l.trim().startsWith('//'))
52
+ .join('\n')
53
+ expect(recordCode).not.toMatch(/pinned: true/)
54
+ })
55
+
56
+ it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
57
+ const closeBody = between(
58
+ gatewaySrc,
59
+ 'const id = turn.activityMessageId\n turn.activityMessageId = null',
60
+ 'if (CLEAR_STATUS_ON_COMPLETION)',
61
+ )
62
+ expect(closeBody.length).toBeGreaterThan(50)
63
+ expect(closeBody).toMatch(/clearActivityCardRecord\(/)
64
+ // Must pass the id so it only drops THIS card's row (not a fresher upsert).
65
+ expect(closeBody).toMatch(/\n\s*id,\s*\n/)
66
+ })
67
+
68
+ it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
69
+ // Both invocation sites (mutex-won + mutex-fallback) sit AFTER
70
+ // acquireStartupLock, alongside statusPinBootCleanup.
71
+ const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
72
+ expect(afterLock).toMatch(/void activityCardBootReaper\(\)/)
73
+ // And it must NOT be invoked at module import time — the same losing-double-
74
+ // boot hazard the NOTE at statusPinBootCleanup documents.
75
+ const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
76
+ expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
77
+ })
78
+
79
+ it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {
80
+ const wrapper = between(
81
+ gatewaySrc,
82
+ 'async function activityCardBootReaper()',
83
+ '\n// NOTE: statusPinBootCleanup()',
84
+ )
85
+ expect(wrapper).toMatch(/vanished/)
86
+ expect(wrapper).toMatch(/at-most-once/)
87
+ })
88
+ })
@@ -18,6 +18,7 @@ import {
18
18
  renderFallbackAnnouncement,
19
19
  buildSnapshotKeyboard,
20
20
  buildSnapshotsFromState,
21
+ zipProbeResults,
21
22
  buildSnapshotsFromCachedState,
22
23
  reviveLastQuota,
23
24
  THROTTLING_THRESHOLD_PCT,
@@ -720,6 +721,80 @@ describe('buildSnapshotKeyboard', () => {
720
721
  .flat().map((b) => b.text);
721
722
  expect(before).not.toContain('Switch fleet → refilled@x');
722
723
  });
724
+
725
+ it('demo mode masks the switch-button label but keeps the real label in callback_data', () => {
726
+ __resetDemoMaskCachesForTest();
727
+ const snaps: AccountSnapshot[] = [
728
+ snap({ label: 'ken.real@example.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
729
+ snap({ label: 'alt.real@example.com', quota: quota({ fiveHourUtilizationPct: 5 }) }),
730
+ ];
731
+ const rows = buildSnapshotKeyboard(snaps, { now: NOW, demo: true });
732
+ const switchBtn = rows.flat().find((b) => b.callbackData?.startsWith('auth:use:'));
733
+ expect(switchBtn).toBeDefined();
734
+ // Label masked — the real email never appears on screen…
735
+ expect(switchBtn!.text).not.toContain('alt.real@example.com');
736
+ // …but the broker still gets the real label to act on.
737
+ expect(switchBtn!.callbackData).toBe('auth:use:alt.real@example.com');
738
+ });
739
+
740
+ it('demo mode flips the refresh callback to auth:refresh:demo so a ↻ tap stays masked', () => {
741
+ const snaps: AccountSnapshot[] = [
742
+ snap({ label: 'a@x', isActive: true, quota: quota({}) }),
743
+ ];
744
+ const demoRows = buildSnapshotKeyboard(snaps, { demo: true }).flat();
745
+ expect(demoRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh:demo');
746
+ const plainRows = buildSnapshotKeyboard(snaps).flat();
747
+ expect(plainRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh');
748
+ });
749
+ });
750
+
751
+ // ── zipProbeResults ──────────────────────────────────────────────────
752
+
753
+ describe('zipProbeResults', () => {
754
+ const okResult = { ok: true as const, data: quota({ fiveHourUtilizationPct: 5 }) };
755
+
756
+ it('returns quotas parallel to labels with no staleCachedAtMs when everything is live', () => {
757
+ const { quotas, staleCachedAtMs } = zipProbeResults(
758
+ ['a@x', 'b@x'],
759
+ [
760
+ { label: 'a@x', result: okResult, served: 'live' },
761
+ { label: 'b@x', result: okResult, served: 'live' },
762
+ ],
763
+ );
764
+ expect(quotas).toHaveLength(2);
765
+ expect(quotas.every((q) => q.ok)).toBe(true);
766
+ expect(staleCachedAtMs).toBeUndefined();
767
+ });
768
+
769
+ it('surfaces the OLDEST capturedAt among cache-served rows (#2495 Change 2)', () => {
770
+ const { staleCachedAtMs } = zipProbeResults(
771
+ ['a@x', 'b@x', 'c@x'],
772
+ [
773
+ { label: 'a@x', result: okResult, served: 'live' },
774
+ { label: 'b@x', result: okResult, served: 'cache', capturedAt: 5_000 },
775
+ { label: 'c@x', result: okResult, served: 'cache', capturedAt: 2_000 },
776
+ ],
777
+ );
778
+ expect(staleCachedAtMs).toBe(2_000);
779
+ });
780
+
781
+ it('degrades a missing per-label row to ok:false, preserving input order', () => {
782
+ const { quotas } = zipProbeResults(
783
+ ['a@x', 'gone@x'],
784
+ [{ label: 'a@x', result: okResult }],
785
+ );
786
+ expect(quotas[0]!.ok).toBe(true);
787
+ expect(quotas[1]!.ok).toBe(false);
788
+ expect((quotas[1] as { ok: false; reason: string }).reason).toContain('no result');
789
+ });
790
+
791
+ it('ignores served:"cache" rows without capturedAt (no false staleness)', () => {
792
+ const { staleCachedAtMs } = zipProbeResults(
793
+ ['a@x'],
794
+ [{ label: 'a@x', result: okResult, served: 'cache' }],
795
+ );
796
+ expect(staleCachedAtMs).toBeUndefined();
797
+ });
723
798
  });
724
799
 
725
800
  // ── buildSnapshotsFromState ──────────────────────────────────────────