switchroom 0.14.47 → 0.14.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +190 -90
- package/telegram-plugin/gateway/boot-card-msgid.ts +70 -0
- package/telegram-plugin/gateway/boot-card.ts +81 -14
- package/telegram-plugin/gateway/gateway.ts +88 -2
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +12 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +17 -1
- package/telegram-plugin/gateway/resume-inbound-builder.ts +20 -4
- package/telegram-plugin/tests/boot-card-edit-in-place.test.ts +139 -0
- package/telegram-plugin/tests/boot-card-msgid.test.ts +88 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +27 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +19 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cross-reboot boot-card message-id store
|
|
3
|
+
* (gateway/boot-card-msgid.ts) — the persistence that lets a routine reboot
|
|
4
|
+
* EDIT the prior boot card in place (zero notification) instead of sending a
|
|
5
|
+
* new one.
|
|
6
|
+
*
|
|
7
|
+
* All I/O is to an isolated mkdtemp dir — NEVER ~/.switchroom (test discipline).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
bootCardChatKey,
|
|
16
|
+
loadBootCardMsgId,
|
|
17
|
+
saveBootCardMsgId,
|
|
18
|
+
} from '../gateway/boot-card-msgid.js'
|
|
19
|
+
|
|
20
|
+
let dir: string
|
|
21
|
+
let path: string
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-card-msgid-'))
|
|
25
|
+
path = join(dir, '.boot-card-msgid.json')
|
|
26
|
+
})
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('bootCardChatKey', () => {
|
|
32
|
+
it('keys a DM (no topic) distinctly from a supergroup topic', () => {
|
|
33
|
+
expect(bootCardChatKey('12345', undefined)).toBe('12345:')
|
|
34
|
+
expect(bootCardChatKey('-1001234567890', 4)).toBe('-1001234567890:4')
|
|
35
|
+
// Different topics in the same supergroup are distinct cards.
|
|
36
|
+
expect(bootCardChatKey('-100', 3)).not.toBe(bootCardChatKey('-100', 4))
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('loadBootCardMsgId / saveBootCardMsgId', () => {
|
|
41
|
+
it('returns null when the file does not exist (first boot)', () => {
|
|
42
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('round-trips a saved id', () => {
|
|
46
|
+
saveBootCardMsgId(path, 'dm:', 353)
|
|
47
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(353)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps ids for different chats independent', () => {
|
|
51
|
+
saveBootCardMsgId(path, 'dm:', 100)
|
|
52
|
+
saveBootCardMsgId(path, '-100:4', 200)
|
|
53
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(100)
|
|
54
|
+
expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
|
|
55
|
+
// Updating one leaves the other intact.
|
|
56
|
+
saveBootCardMsgId(path, 'dm:', 101)
|
|
57
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(101)
|
|
58
|
+
expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns null for an unknown chat key', () => {
|
|
62
|
+
saveBootCardMsgId(path, 'dm:', 1)
|
|
63
|
+
expect(loadBootCardMsgId(path, 'other:')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('rejects non-positive / non-finite ids on save and load', () => {
|
|
67
|
+
saveBootCardMsgId(path, 'dm:', 0)
|
|
68
|
+
saveBootCardMsgId(path, 'dm:', -5)
|
|
69
|
+
saveBootCardMsgId(path, 'dm:', Number.NaN)
|
|
70
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('treats a corrupt file as empty (falls back to fresh send)', () => {
|
|
74
|
+
writeFileSync(path, 'not json{', 'utf8')
|
|
75
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
76
|
+
// And a subsequent save still works (overwrites the garbage).
|
|
77
|
+
saveBootCardMsgId(path, 'dm:', 7)
|
|
78
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(7)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not rewrite the file when the id is unchanged (idempotent)', () => {
|
|
82
|
+
saveBootCardMsgId(path, 'dm:', 42)
|
|
83
|
+
expect(existsSync(path)).toBe(true)
|
|
84
|
+
// Saving the same value again is a no-op; the value is still readable.
|
|
85
|
+
saveBootCardMsgId(path, 'dm:', 42)
|
|
86
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(42)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
|
|
|
220
220
|
expect(calls).toBe(0)
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
// onDelivered: the deliver-until-acked enrol hook (clerk lost-message
|
|
224
|
+
// incident 2026-06-03). A socket-write "success" is not proof claude
|
|
225
|
+
// consumed it; the caller uses onDelivered to enrol the redelivered inbound
|
|
226
|
+
// in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
|
|
227
|
+
it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
|
|
228
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
229
|
+
buf.push('klanker', inbound('user', 1))
|
|
230
|
+
buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
|
|
231
|
+
const delivered: number[] = []
|
|
232
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
|
|
233
|
+
delivered.push(merged.messageId as number)
|
|
234
|
+
})
|
|
235
|
+
expect(r.redelivered).toBe(2)
|
|
236
|
+
expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
|
|
240
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
241
|
+
buf.push('klanker', inbound('user', 1))
|
|
242
|
+
const delivered: number[] = []
|
|
243
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
|
|
244
|
+
delivered.push(m.messageId as number),
|
|
245
|
+
)
|
|
246
|
+
expect(r.rebuffered).toBe(1)
|
|
247
|
+
expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
|
|
248
|
+
})
|
|
249
|
+
|
|
223
250
|
it('only touches the named agent', () => {
|
|
224
251
|
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
225
252
|
buf.push('klanker', inbound('user', 1))
|
|
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
|
|
|
179
179
|
expect(selectResumeBuilder(endedVia)).toBe(expected)
|
|
180
180
|
})
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
// 3h staleness failsafe (operator spec, 2026-06-03).
|
|
184
|
+
const MAX = 10_800_000 // 3h
|
|
185
|
+
it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
|
|
186
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
187
|
+
expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
|
|
188
|
+
})
|
|
189
|
+
it('keeps resume when within maxAgeMs', () => {
|
|
190
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
|
|
191
|
+
expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
|
|
192
|
+
})
|
|
193
|
+
it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
|
|
194
|
+
expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
195
|
+
expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
|
|
196
|
+
})
|
|
197
|
+
it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
|
|
198
|
+
expect(selectResumeBuilder('restart')).toBe('resume')
|
|
199
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
|
|
200
|
+
})
|
|
182
201
|
})
|