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
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Atomic-write + corruption contract for the gateway's four small JSON state
3
+ * stores: `pending-card-store`, `scoped-grant-store`, `missed-approvals-store`,
4
+ * `always-allow-persist-queue`.
5
+ *
6
+ * The bug these pin: each store did a non-atomic read-modify-write straight
7
+ * over the destination (`writeFileSync(dest, …)` = open + O_TRUNC + write),
8
+ * and wrapped the read in `catch { return [] }`. A crash between the truncate
9
+ * and the write left a TORN file; on the next boot the parse threw, the catch
10
+ * swallowed it, and every pending approval card / live scoped grant was
11
+ * silently forgotten. Nothing was logged. The operator just saw dead buttons.
12
+ *
13
+ * SCOPE OF THE CLAIM — deliberately narrow. What is asserted here is ATOMIC
14
+ * REPLACEMENT: a reader (or a crash) sees the whole old file or the whole new
15
+ * file, never a half-written one. That is NOT the same as crash-durability
16
+ * against power loss: `atomicWriteFileSync` fsyncs the tempfile but not the
17
+ * PARENT DIRECTORY, so an unjournalled dirent can still revert the rename
18
+ * after a power cut (tracked as a separate follow-up against
19
+ * `src/util/atomic.ts`, which is shared with vault entries and OAuth tokens
20
+ * and needs its own review). Whole-old-or-whole-new holds either way, and
21
+ * that is what the torn-file bug was about.
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
25
+ import {
26
+ mkdtempSync,
27
+ mkdirSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ readFileSync,
31
+ readdirSync,
32
+ statSync,
33
+ utimesSync,
34
+ } from 'node:fs'
35
+ import { join } from 'node:path'
36
+ import { tmpdir } from 'node:os'
37
+
38
+ import { createPendingCardStore, type PersistedApprovalCard } from '../gateway/pending-card-store.js'
39
+ import { createScopedGrantStore } from '../gateway/scoped-grant-store.js'
40
+ import { createMissedApprovalsStore, type MissedApproval } from '../gateway/missed-approvals-store.js'
41
+ import {
42
+ atomicWriteSeam,
43
+ createAlwaysAllowPersistQueue,
44
+ } from '../gateway/always-allow-persist-queue.js'
45
+ import { MAX_QUARANTINED_COPIES, quarantineCorruptStoreFile } from '../gateway/store-file.js'
46
+
47
+ let dir: string
48
+ let logged: string[]
49
+ const log = (line: string) => {
50
+ logged.push(line)
51
+ }
52
+
53
+ beforeEach(() => {
54
+ dir = mkdtempSync(join(tmpdir(), 'store-atomic-write-'))
55
+ logged = []
56
+ })
57
+ afterEach(() => {
58
+ rmSync(dir, { recursive: true, force: true })
59
+ })
60
+
61
+ /** The bytes a crash between truncate and write leaves behind. */
62
+ const TORN = '[{"family":"vault_request_access","stageId":"abc'
63
+
64
+ function quarantinedFiles(base: string): string[] {
65
+ return readdirSync(dir).filter(f => f.startsWith(`${base}.corrupt-`))
66
+ }
67
+
68
+ function sampleCard(stageId: string): PersistedApprovalCard {
69
+ return {
70
+ family: 'vault_request_access',
71
+ stageId,
72
+ agent: 'clerk',
73
+ chatId: '123',
74
+ stagedAt: 1_700_000_000_000,
75
+ key: 'coolify/api-token',
76
+ scope: 'read',
77
+ ttlSeconds: 900,
78
+ }
79
+ }
80
+
81
+ function sampleMiss(requestId: string): MissedApproval {
82
+ return {
83
+ requestId,
84
+ toolName: 'mcp__brevo__post',
85
+ action: 'post to Brevo',
86
+ chatId: '123',
87
+ timedOutAt: 1_700_000_000_000,
88
+ }
89
+ }
90
+
91
+ describe('a torn file must NOT silently yield an empty store', () => {
92
+ it('pending-card-store: quarantines the torn bytes and logs loudly', () => {
93
+ writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
94
+
95
+ const store = createPendingCardStore(dir, log)
96
+ expect(store.loadAll()).toEqual([])
97
+
98
+ // The loss is OBSERVABLE: corrupt bytes preserved + a loud log line.
99
+ const quarantined = quarantinedFiles('pending-approval-cards.json')
100
+ expect(quarantined).toHaveLength(1)
101
+ expect(readFileSync(join(dir, quarantined[0]!), 'utf-8')).toBe(TORN)
102
+ expect(logged.join('')).toMatch(/pending-card-store CORRUPT/)
103
+ expect(logged.join('')).toMatch(/LOST/)
104
+ })
105
+
106
+ it('scoped-grant-store: quarantines the torn bytes and logs loudly', () => {
107
+ writeFileSync(join(dir, 'scoped-grants.json'), TORN)
108
+
109
+ const store = createScopedGrantStore(dir, {}, log)
110
+ expect(store.load(Date.now()).size).toBe(0)
111
+
112
+ expect(quarantinedFiles('scoped-grants.json')).toHaveLength(1)
113
+ expect(logged.join('')).toMatch(/scoped-grant-store CORRUPT/)
114
+ })
115
+
116
+ it('missed-approvals-store: quarantines the torn bytes and logs loudly', () => {
117
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[{"requestId":"r1"')
118
+
119
+ const store = createMissedApprovalsStore(dir, log)
120
+ expect(store.listPending()).toEqual([])
121
+
122
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
123
+ expect(logged.join('')).toMatch(/missed-approvals-store CORRUPT/)
124
+ })
125
+
126
+ it('always-allow-persist-queue: quarantines the torn bytes and logs loudly', () => {
127
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":[{"id":"clerk::Ski')
128
+
129
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
130
+ expect(q.listAll()).toEqual([])
131
+
132
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
133
+ expect(logged.join('')).toMatch(/always-allow-persist-queue CORRUPT/)
134
+ })
135
+
136
+ it('a MISSING file is the normal cold start — no quarantine, no alarm', () => {
137
+ const store = createPendingCardStore(dir, log)
138
+ expect(store.loadAll()).toEqual([])
139
+ expect(readdirSync(dir)).toEqual([])
140
+ expect(logged).toEqual([])
141
+ })
142
+
143
+ it('quarantine happens once — the next boot starts clean instead of re-alarming', () => {
144
+ writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
145
+ createPendingCardStore(dir, log).loadAll()
146
+ logged = []
147
+
148
+ createPendingCardStore(dir, log).loadAll()
149
+ expect(logged).toEqual([])
150
+ expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
151
+ })
152
+ })
153
+
154
+ describe('valid JSON whose LIST FIELD is not a list is corruption, not "empty"', () => {
155
+ // The original silent-loss bug in a second costume: `{"pending": null}` and
156
+ // `{"entries": {}}` parse fine and reach an object at top level, so a naive
157
+ // `Array.isArray(x) ? x : []` coercion swallows them exactly the way the
158
+ // old `catch { return [] }` did — dead buttons, nothing logged.
159
+
160
+ it('missed-approvals-store: `pending` present but null quarantines', () => {
161
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":null,"delivered":[]}')
162
+
163
+ expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
164
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
165
+ expect(logged.join('')).toMatch(/`pending` is present but not an array/)
166
+ })
167
+
168
+ it('missed-approvals-store: `delivered` present but an object quarantines', () => {
169
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[],"delivered":{}}')
170
+
171
+ expect(createMissedApprovalsStore(dir, log).getDigest('d1')).toBeUndefined()
172
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
173
+ expect(logged.join('')).toMatch(/`delivered` is present but not an array/)
174
+ })
175
+
176
+ it('always-allow-persist-queue: `entries` present but an object quarantines', () => {
177
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":{}}')
178
+
179
+ expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
180
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
181
+ expect(logged.join('')).toMatch(/`entries` is present but not an array/)
182
+ })
183
+
184
+ it('an ABSENT list is the legitimate partial shape — silent, no quarantine', () => {
185
+ writeFileSync(join(dir, 'missed-approvals.json'), '{}')
186
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{}')
187
+
188
+ expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
189
+ expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
190
+
191
+ expect(quarantinedFiles('missed-approvals.json')).toEqual([])
192
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toEqual([])
193
+ expect(logged).toEqual([])
194
+ })
195
+ })
196
+
197
+ describe('an update replaces the destination by rename — it is never truncated in place', () => {
198
+ /**
199
+ * rename(2) swaps a NEW inode into the destination name, so a reader (or a
200
+ * crash) only ever sees the whole old file or the whole new file. An
201
+ * in-place `writeFileSync` keeps the destination inode and truncates it
202
+ * first — that truncate window is the torn-file bug. Inode identity is the
203
+ * observable difference.
204
+ */
205
+ it('pending-card-store', () => {
206
+ const store = createPendingCardStore(dir, log)
207
+ store.add(sampleCard('s1'))
208
+ const file = join(dir, 'pending-approval-cards.json')
209
+ const first = statSync(file).ino
210
+
211
+ store.add(sampleCard('s2'))
212
+ expect(statSync(file).ino).not.toBe(first)
213
+ expect(store.loadAll().map(c => c.stageId)).toEqual(['s1', 's2'])
214
+ // No tempfile left behind on a successful write.
215
+ expect(readdirSync(dir)).toEqual(['pending-approval-cards.json'])
216
+ // Perms stay owner-only even across a replace.
217
+ expect(statSync(file).mode & 0o777).toBe(0o600)
218
+ })
219
+
220
+ it('scoped-grant-store', () => {
221
+ const store = createScopedGrantStore(dir, {}, log)
222
+ store.save(new Map())
223
+ const file = join(dir, 'scoped-grants.json')
224
+ const first = statSync(file).ino
225
+
226
+ store.save(new Map())
227
+ expect(statSync(file).ino).not.toBe(first)
228
+ expect(readdirSync(dir)).toEqual(['scoped-grants.json'])
229
+ expect(statSync(file).mode & 0o777).toBe(0o600)
230
+ })
231
+
232
+ it('missed-approvals-store', () => {
233
+ const store = createMissedApprovalsStore(dir, log)
234
+ store.add(sampleMiss('r1'))
235
+ const file = join(dir, 'missed-approvals.json')
236
+ const first = statSync(file).ino
237
+
238
+ store.add(sampleMiss('r2'))
239
+ expect(statSync(file).ino).not.toBe(first)
240
+ expect(store.listPending().map(e => e.requestId)).toEqual(['r1', 'r2'])
241
+ expect(readdirSync(dir)).toEqual(['missed-approvals.json'])
242
+ expect(statSync(file).mode & 0o777).toBe(0o600)
243
+ })
244
+
245
+ it('always-allow-persist-queue', async () => {
246
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
247
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
248
+ const file = join(dir, 'always-allow-persist-queue.json')
249
+ const first = statSync(file).ino
250
+
251
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' })
252
+ expect(statSync(file).ino).not.toBe(first)
253
+ expect(q.listAll()).toHaveLength(2)
254
+ expect(readdirSync(dir)).toEqual(['always-allow-persist-queue.json'])
255
+ expect(statSync(file).mode & 0o777).toBe(0o600)
256
+ })
257
+ })
258
+
259
+ describe('a write failure is surfaced and leaves the previous good content intact', () => {
260
+ // NOTE ON WHAT THIS PROVES: the good write below goes through the REAL
261
+ // atomic writer (`atomicWriteSeam`); only the second call is diverted to a
262
+ // thrower. So this asserts failure PROPAGATION plus "the destination still
263
+ // holds the last good snapshot" — it does NOT by itself prove atomicity
264
+ // (the inode suite above does that).
265
+ it('always-allow-persist-queue: the entry queued before the failure is still readable', async () => {
266
+ let fail = false
267
+ const seam = ((...args: Parameters<typeof atomicWriteSeam>) => {
268
+ if (fail) throw new Error('ENOSPC: no space left on device')
269
+ return atomicWriteSeam(...args)
270
+ }) as typeof atomicWriteSeam
271
+
272
+ const q = createAlwaysAllowPersistQueue(dir, seam, log)
273
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
274
+ const file = join(dir, 'always-allow-persist-queue.json')
275
+ const before = readFileSync(file, 'utf-8')
276
+ const beforeIno = statSync(file).ino
277
+
278
+ fail = true
279
+ await expect(
280
+ q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' }),
281
+ ).rejects.toThrow(/ENOSPC/)
282
+
283
+ // Byte-identical AND the same inode — the failed write never even touched
284
+ // the destination, let alone truncated it.
285
+ expect(readFileSync(file, 'utf-8')).toBe(before)
286
+ expect(statSync(file).ino).toBe(beforeIno)
287
+ fail = false
288
+ expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
289
+ })
290
+ })
291
+
292
+ describe('an UNREADABLE file (not ENOENT) is loud, is never quarantined on read, and fails closed on write', () => {
293
+ // A directory at the store path makes `readFileSync` fail EISDIR — a
294
+ // deterministic stand-in for the flaky-mount EIO / transient EACCES class
295
+ // (chmod tricks don't work: CI and the containers run as root).
296
+ it('pending-card-store: logs loudly and does NOT quarantine on the read', () => {
297
+ mkdirSync(join(dir, 'pending-approval-cards.json'))
298
+
299
+ expect(createPendingCardStore(dir, log).loadAll()).toEqual([])
300
+
301
+ expect(logged.join('')).toMatch(/pending-card-store read FAILED/)
302
+ expect(logged.join('')).toMatch(/EISDIR/)
303
+ // Crucially NOT quarantined — a file we merely failed to read may still
304
+ // hold perfectly good state, and a transient fault must not destroy it.
305
+ expect(quarantinedFiles('pending-approval-cards.json')).toEqual([])
306
+ })
307
+
308
+ it('pending-card-store: the next write PRESERVES the unreadable file instead of clobbering it', () => {
309
+ mkdirSync(join(dir, 'pending-approval-cards.json'))
310
+ const store = createPendingCardStore(dir, log)
311
+
312
+ store.add(sampleCard('s1'))
313
+
314
+ // The bytes we could not read were moved aside, not overwritten...
315
+ expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
316
+ expect(logged.join('')).toMatch(/Preserved the previous \(unreadable\) bytes/)
317
+ // ...and the store carried on with a fresh, valid file.
318
+ expect(store.loadAll().map(c => c.stageId)).toEqual(['s1'])
319
+ })
320
+
321
+ it('always-allow-persist-queue: the preserve step runs before the write', async () => {
322
+ mkdirSync(join(dir, 'always-allow-persist-queue.json'))
323
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
324
+
325
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
326
+
327
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
328
+ expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
329
+ })
330
+ })
331
+
332
+ describe('kill switch: a disabled scoped-grant store never touches the file', () => {
333
+ it('does not read, quarantine, or write when SWITCHROOM_SCOPED_GRANT_PERSIST=0', () => {
334
+ writeFileSync(join(dir, 'scoped-grants.json'), TORN)
335
+ const store = createScopedGrantStore(dir, { SWITCHROOM_SCOPED_GRANT_PERSIST: '0' }, log)
336
+
337
+ expect(store.enabled).toBe(false)
338
+ expect(store.load(Date.now()).size).toBe(0)
339
+ store.save(new Map())
340
+
341
+ // The pre-existing (corrupt) file is left exactly as found — a disabled
342
+ // store must not be the thing that moves the operator's data around.
343
+ expect(readFileSync(join(dir, 'scoped-grants.json'), 'utf-8')).toBe(TORN)
344
+ expect(quarantinedFiles('scoped-grants.json')).toEqual([])
345
+ expect(logged).toEqual([])
346
+ })
347
+ })
348
+
349
+ describe('quarantine copies do not collide and do not accumulate without bound', () => {
350
+ it('two quarantines in the same millisecond both survive (random suffix)', () => {
351
+ const file = join(dir, 'x.json')
352
+ for (const bytes of ['first', 'second']) {
353
+ writeFileSync(file, bytes)
354
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
355
+ }
356
+ const copies = quarantinedFiles('x.json')
357
+ expect(copies).toHaveLength(2)
358
+ expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(['first', 'second'])
359
+ })
360
+
361
+ it(`keeps at most ${MAX_QUARANTINED_COPIES} forensic copies, newest wins`, () => {
362
+ const file = join(dir, 'x.json')
363
+ const total = MAX_QUARANTINED_COPIES + 4
364
+ for (let i = 0; i < total; i++) {
365
+ writeFileSync(file, `copy-${i}`)
366
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
367
+ }
368
+ const copies = quarantinedFiles('x.json')
369
+ expect(copies).toHaveLength(MAX_QUARANTINED_COPIES)
370
+ // The reap drops the OLDEST — exactly the newest MAX are kept, even
371
+ // though these all land in the same millisecond (so the ordering cannot
372
+ // be coming from the embedded timestamp alone).
373
+ expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(
374
+ Array.from({ length: MAX_QUARANTINED_COPIES }, (_, i) => `copy-${total - MAX_QUARANTINED_COPIES + i}`),
375
+ )
376
+ })
377
+
378
+ it('a RESTARTED process does not get its fresh copy reaped as "oldest"', () => {
379
+ // Regression: ordering used to come from the `<epoch-ms>-<seq>` embedded
380
+ // in the filename, but `seq` restarts at 0 on every process boot. A prior
381
+ // process that burned seq 000001..000005 within millisecond T, followed
382
+ // by a restart quarantining in that same T, produced a NEW copy named
383
+ // `T-000000-…` — lexicographically the OLDEST of the set, so the reaper
384
+ // deleted precisely the forensic bytes the operator needed. Ordering is
385
+ // by mtime now, which no process restart can rewind.
386
+ const file = join(dir, 'x.json')
387
+ const stamp = Date.now()
388
+ for (let i = 1; i <= MAX_QUARANTINED_COPIES; i++) {
389
+ // The prior process's sequence numbers — deliberately HIGHER than the
390
+ // restarted process's (whose counter is back near 0), which is the
391
+ // whole point: a name-ordered reaper would rank the fresh copy oldest.
392
+ const seeded = `${file}.corrupt-${stamp}-${String(900_000 + i).padStart(6, '0')}-aaaaaaaa`
393
+ writeFileSync(seeded, `old-${i}`)
394
+ // Genuinely older on disk (the prior process wrote them seconds ago),
395
+ // while still carrying the same embedded millisecond in the NAME — so
396
+ // a name-ordered reaper and an mtime-ordered one disagree, which is
397
+ // exactly the discriminating case.
398
+ const past = new Date(Date.now() - (MAX_QUARANTINED_COPIES + 1 - i) * 1000)
399
+ utimesSync(seeded, past, past)
400
+ }
401
+
402
+ // The restarted process quarantines fresh bytes; its seq is back at 0.
403
+ writeFileSync(file, 'NEWEST-FORENSIC-BYTES')
404
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
405
+
406
+ const kept = quarantinedFiles('x.json').map(f => readFileSync(join(dir, f), 'utf-8'))
407
+ expect(kept).toHaveLength(MAX_QUARANTINED_COPIES)
408
+ expect(kept).toContain('NEWEST-FORENSIC-BYTES')
409
+ expect(kept).not.toContain('old-1') // the genuinely oldest went instead
410
+ })
411
+ })