switchroom 0.18.11 → 0.18.13
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/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createSendGate, sendGateEnabledFromEnv, type Clock } from './send-gate.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic fake clock. `sleep()` registers a wake at `now + ms`;
|
|
6
|
+
* `advance(ms)` walks time forward, resolving due timers in (time, insertion)
|
|
7
|
+
* order and flushing microtasks between each so continuations can register the
|
|
8
|
+
* next sleep before we advance further. No real timers, no `Date.now()` — the
|
|
9
|
+
* scheduler is fully driven by the test.
|
|
10
|
+
*/
|
|
11
|
+
class FakeClock implements Clock {
|
|
12
|
+
private cur = 0
|
|
13
|
+
private seq = 0
|
|
14
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
15
|
+
|
|
16
|
+
now(): number {
|
|
17
|
+
return this.cur
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise<void>((resolve) => {
|
|
22
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async advance(ms: number): Promise<void> {
|
|
27
|
+
const target = this.cur + ms
|
|
28
|
+
for (;;) {
|
|
29
|
+
const due = this.timers
|
|
30
|
+
.filter((t) => t.at <= target)
|
|
31
|
+
.sort((a, b) => a.at - b.at || a.id - b.id)
|
|
32
|
+
if (due.length === 0) break
|
|
33
|
+
const t = due[0]
|
|
34
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
35
|
+
this.cur = t.at
|
|
36
|
+
t.resolve()
|
|
37
|
+
await flush()
|
|
38
|
+
}
|
|
39
|
+
this.cur = target
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Flush all pending microtasks (real macrotask hop; no fake timers involved). */
|
|
44
|
+
function flush(): Promise<void> {
|
|
45
|
+
return new Promise<void>((r) => setImmediate(r))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Rec {
|
|
49
|
+
label: string
|
|
50
|
+
at: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function recorder(clock: FakeClock) {
|
|
54
|
+
const calls: Rec[] = []
|
|
55
|
+
const fn = (label: string) => async () => {
|
|
56
|
+
calls.push({ label, at: clock.now() })
|
|
57
|
+
return label
|
|
58
|
+
}
|
|
59
|
+
return { calls, fn }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('send-gate: feature flag', () => {
|
|
63
|
+
it('passes straight through and never delays when disabled', async () => {
|
|
64
|
+
const clock = new FakeClock()
|
|
65
|
+
const { calls, fn } = recorder(clock)
|
|
66
|
+
const gate = createSendGate({ enabled: false, clock, globalPerSec: 1 })
|
|
67
|
+
|
|
68
|
+
// Fire far more than any bucket would allow; all must run immediately.
|
|
69
|
+
await Promise.all([gate.gate(fn('a')), gate.gate(fn('b')), gate.gate(fn('c'))])
|
|
70
|
+
|
|
71
|
+
expect(calls.map((c) => c.label).sort()).toEqual(['a', 'b', 'c'])
|
|
72
|
+
expect(calls.every((c) => c.at === 0)).toBe(true)
|
|
73
|
+
// No counters move when the gate is off.
|
|
74
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
75
|
+
expect(gate.stats().enabled).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('bypasses the edit floor entirely when disabled', async () => {
|
|
79
|
+
const clock = new FakeClock()
|
|
80
|
+
const { calls, fn } = recorder(clock)
|
|
81
|
+
const gate = createSendGate({ enabled: false, clock })
|
|
82
|
+
|
|
83
|
+
await gate.gate(fn('v1'), { messageId: 7, editPayload: 'v1' })
|
|
84
|
+
await gate.gate(fn('v2'), { messageId: 7, editPayload: 'v2' })
|
|
85
|
+
|
|
86
|
+
// Both edits fire at t=0 — no coalescing, no floor.
|
|
87
|
+
expect(calls.map((c) => c.label)).toEqual(['v1', 'v2'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('sendGateEnabledFromEnv is ON BY DEFAULT (escape hatch, not opt-in)', () => {
|
|
91
|
+
// Unset → enabled. This assertion is load-bearing: it fails if the default
|
|
92
|
+
// is ever flipped back to off.
|
|
93
|
+
expect(sendGateEnabledFromEnv({} as NodeJS.ProcessEnv)).toBe(true)
|
|
94
|
+
expect(
|
|
95
|
+
sendGateEnabledFromEnv({ SWITCHROOM_TELEGRAM_SEND_GATE: undefined } as NodeJS.ProcessEnv),
|
|
96
|
+
).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('sendGateEnabledFromEnv disables only on explicit off values (safety valve)', () => {
|
|
100
|
+
for (const off of ['0', 'false', 'off', 'no', 'FALSE', 'Off', ' no ', ' 0 ']) {
|
|
101
|
+
expect(
|
|
102
|
+
sendGateEnabledFromEnv({
|
|
103
|
+
SWITCHROOM_TELEGRAM_SEND_GATE: off,
|
|
104
|
+
} as unknown as NodeJS.ProcessEnv),
|
|
105
|
+
).toBe(false)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('sendGateEnabledFromEnv stays enabled for any other value', () => {
|
|
110
|
+
for (const on of ['1', 'true', 'on', 'yes', 'enabled', '', 'anything']) {
|
|
111
|
+
expect(
|
|
112
|
+
sendGateEnabledFromEnv({
|
|
113
|
+
SWITCHROOM_TELEGRAM_SEND_GATE: on,
|
|
114
|
+
} as unknown as NodeJS.ProcessEnv),
|
|
115
|
+
).toBe(true)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('send-gate: global bucket', () => {
|
|
121
|
+
it('admits up to capacity immediately, then paces the rest by refill', async () => {
|
|
122
|
+
const clock = new FakeClock()
|
|
123
|
+
const { calls, fn } = recorder(clock)
|
|
124
|
+
// 2/sec → burst 2, one token every 500ms.
|
|
125
|
+
const gate = createSendGate({ enabled: true, clock, globalPerSec: 2, globalBurst: 2 })
|
|
126
|
+
|
|
127
|
+
const p1 = gate.gate(fn('a'))
|
|
128
|
+
const p2 = gate.gate(fn('b'))
|
|
129
|
+
const p3 = gate.gate(fn('c'))
|
|
130
|
+
await flush()
|
|
131
|
+
|
|
132
|
+
// First two consume the burst immediately; third is queued.
|
|
133
|
+
expect(calls.map((c) => c.label)).toEqual(['a', 'b'])
|
|
134
|
+
expect(gate.stats().global.queued).toBe(1)
|
|
135
|
+
|
|
136
|
+
await clock.advance(500)
|
|
137
|
+
await Promise.all([p1, p2, p3])
|
|
138
|
+
|
|
139
|
+
expect(calls.map((c) => c.label)).toEqual(['a', 'b', 'c'])
|
|
140
|
+
expect(calls.find((c) => c.label === 'c')?.at).toBe(500)
|
|
141
|
+
expect(gate.stats().global.sent).toBe(3)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('send-gate: per-chat bucket', () => {
|
|
146
|
+
it('allows a burst of 3 per chat then paces at 1/sec; other chats are independent', async () => {
|
|
147
|
+
const clock = new FakeClock()
|
|
148
|
+
const { calls, fn } = recorder(clock)
|
|
149
|
+
const gate = createSendGate({
|
|
150
|
+
enabled: true,
|
|
151
|
+
clock,
|
|
152
|
+
globalPerSec: 1000, // global not the limiter here
|
|
153
|
+
perChatPerSec: 1,
|
|
154
|
+
perChatBurst: 3,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const chatA = { chat_id: 'A' }
|
|
158
|
+
const chatB = { chat_id: 'B' }
|
|
159
|
+
|
|
160
|
+
const ps = [
|
|
161
|
+
gate.gate(fn('a1'), chatA),
|
|
162
|
+
gate.gate(fn('a2'), chatA),
|
|
163
|
+
gate.gate(fn('a3'), chatA),
|
|
164
|
+
gate.gate(fn('a4'), chatA), // 4th on A must wait ~1s
|
|
165
|
+
gate.gate(fn('b1'), chatB), // different chat → immediate
|
|
166
|
+
]
|
|
167
|
+
await flush()
|
|
168
|
+
|
|
169
|
+
const now0 = calls.filter((c) => c.at === 0).map((c) => c.label).sort()
|
|
170
|
+
expect(now0).toEqual(['a1', 'a2', 'a3', 'b1'])
|
|
171
|
+
expect(calls.some((c) => c.label === 'a4')).toBe(false)
|
|
172
|
+
|
|
173
|
+
await clock.advance(1000)
|
|
174
|
+
await Promise.all(ps)
|
|
175
|
+
|
|
176
|
+
expect(calls.find((c) => c.label === 'a4')?.at).toBe(1000)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('send-gate: per-group bucket', () => {
|
|
181
|
+
it('keys a per-group ceiling on chat type and paces group traffic by the minute', async () => {
|
|
182
|
+
const clock = new FakeClock()
|
|
183
|
+
const { calls, fn } = recorder(clock)
|
|
184
|
+
const gate = createSendGate({
|
|
185
|
+
enabled: true,
|
|
186
|
+
clock,
|
|
187
|
+
globalPerSec: 1000,
|
|
188
|
+
perChatPerSec: 1000,
|
|
189
|
+
perChatBurst: 1000, // neither global nor per-chat is the limiter
|
|
190
|
+
perGroupPerMin: 2, // 2/min → one token every 30s
|
|
191
|
+
perGroupBurst: 2,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const group = { chat_id: 'G', chatType: 'supergroup' as const }
|
|
195
|
+
|
|
196
|
+
const ps = [
|
|
197
|
+
gate.gate(fn('g1'), group),
|
|
198
|
+
gate.gate(fn('g2'), group),
|
|
199
|
+
gate.gate(fn('g3'), group), // 3rd waits 30s
|
|
200
|
+
]
|
|
201
|
+
await flush()
|
|
202
|
+
|
|
203
|
+
expect(calls.map((c) => c.label).sort()).toEqual(['g1', 'g2'])
|
|
204
|
+
|
|
205
|
+
await clock.advance(30_000)
|
|
206
|
+
await Promise.all(ps)
|
|
207
|
+
|
|
208
|
+
expect(calls.find((c) => c.label === 'g3')?.at).toBe(30_000)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('does NOT apply the per-group bucket to private chats', async () => {
|
|
212
|
+
const clock = new FakeClock()
|
|
213
|
+
const { calls, fn } = recorder(clock)
|
|
214
|
+
const gate = createSendGate({
|
|
215
|
+
enabled: true,
|
|
216
|
+
clock,
|
|
217
|
+
globalPerSec: 1000,
|
|
218
|
+
perChatPerSec: 1000,
|
|
219
|
+
perChatBurst: 1000,
|
|
220
|
+
perGroupPerMin: 1, // would block the 2nd if it applied
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const dm = { chat_id: 'P', chatType: 'private' as const }
|
|
224
|
+
await Promise.all([gate.gate(fn('p1'), dm), gate.gate(fn('p2'), dm)])
|
|
225
|
+
|
|
226
|
+
// Both private-chat sends land at t=0 — the group bucket never engages.
|
|
227
|
+
expect(calls.map((c) => c.at)).toEqual([0, 0])
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('send-gate: same-message edit floor + coalescing', () => {
|
|
232
|
+
it('coalesces edits within the floor and sends ONLY the last payload', async () => {
|
|
233
|
+
const clock = new FakeClock()
|
|
234
|
+
const { calls, fn } = recorder(clock)
|
|
235
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
236
|
+
const msg = 42
|
|
237
|
+
|
|
238
|
+
// First edit fires immediately (no prior edit).
|
|
239
|
+
await gate.gate(fn('v1'), { messageId: msg, editPayload: 'v1' })
|
|
240
|
+
expect(calls.map((c) => c.label)).toEqual(['v1'])
|
|
241
|
+
|
|
242
|
+
// Two more edits arrive within the 1.5s floor → the second replaces the
|
|
243
|
+
// first pending payload (last-write-wins), never queues behind it.
|
|
244
|
+
const p2 = gate.gate(fn('v2'), { messageId: msg, editPayload: 'v2' })
|
|
245
|
+
const p3 = gate.gate(fn('v3'), { messageId: msg, editPayload: 'v3' })
|
|
246
|
+
await flush()
|
|
247
|
+
|
|
248
|
+
// Nothing new has hit the API yet — still inside the floor.
|
|
249
|
+
expect(calls.map((c) => c.label)).toEqual(['v1'])
|
|
250
|
+
expect(gate.stats().global.coalesced).toBe(1)
|
|
251
|
+
|
|
252
|
+
await clock.advance(1500)
|
|
253
|
+
await Promise.all([p2, p3])
|
|
254
|
+
|
|
255
|
+
// Exactly ONE additional send, carrying the LAST payload (v3); v2 dropped.
|
|
256
|
+
expect(calls.map((c) => c.label)).toEqual(['v1', 'v3'])
|
|
257
|
+
expect(calls.find((c) => c.label === 'v3')?.at).toBe(1500)
|
|
258
|
+
expect(gate.stats().global.sent).toBe(2)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('spaces sequential distinct edits at least the floor apart', async () => {
|
|
262
|
+
const clock = new FakeClock()
|
|
263
|
+
const { calls, fn } = recorder(clock)
|
|
264
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
265
|
+
const msg = 5
|
|
266
|
+
|
|
267
|
+
await gate.gate(fn('a'), { messageId: msg, editPayload: 'a' })
|
|
268
|
+
const p = gate.gate(fn('b'), { messageId: msg, editPayload: 'b' })
|
|
269
|
+
await flush()
|
|
270
|
+
expect(calls.map((c) => c.label)).toEqual(['a']) // b waits for the floor
|
|
271
|
+
|
|
272
|
+
await clock.advance(1500)
|
|
273
|
+
await p
|
|
274
|
+
expect(calls.find((c) => c.label === 'b')?.at).toBe(1500)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('send-gate: no-op edit skip', () => {
|
|
279
|
+
it('drops an identical repeat payload without calling the API', async () => {
|
|
280
|
+
const clock = new FakeClock()
|
|
281
|
+
const { calls, fn } = recorder(clock)
|
|
282
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
283
|
+
const msg = 9
|
|
284
|
+
|
|
285
|
+
await gate.gate(fn('x1'), { messageId: msg, editPayload: { text: 'same' } })
|
|
286
|
+
await clock.advance(1500) // clear the floor
|
|
287
|
+
|
|
288
|
+
// Identical rendered payload → dropped before the API.
|
|
289
|
+
const r = await gate.gate(fn('x2'), { messageId: msg, editPayload: { text: 'same' } })
|
|
290
|
+
expect(r).toBeUndefined()
|
|
291
|
+
expect(calls.map((c) => c.label)).toEqual(['x1'])
|
|
292
|
+
expect(gate.stats().global.dropped).toBe(1)
|
|
293
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('last write reverting to the on-screen payload wins over a queued distinct edit (N1)', async () => {
|
|
297
|
+
const clock = new FakeClock()
|
|
298
|
+
const { calls, fn } = recorder(clock)
|
|
299
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
300
|
+
const msg = 11
|
|
301
|
+
|
|
302
|
+
await gate.gate(fn('A'), { messageId: msg, editPayload: 'A' }) // sends A (on screen)
|
|
303
|
+
const p2 = gate.gate(fn('B'), { messageId: msg, editPayload: 'B' }) // queued within floor
|
|
304
|
+
// The genuinely-LAST write reverts to the on-screen payload (A). Under
|
|
305
|
+
// last-write-wins it must REPLACE the queued distinct edit (B), not be
|
|
306
|
+
// dropped as a no-op while B still renders. The driver's own no-op re-check
|
|
307
|
+
// then drops the coalesced revert, so nothing needless hits the API and the
|
|
308
|
+
// final screen stays A — B never renders.
|
|
309
|
+
const p3 = gate.gate(fn('A2'), { messageId: msg, editPayload: 'A' })
|
|
310
|
+
|
|
311
|
+
await clock.advance(1500)
|
|
312
|
+
await Promise.all([p2, p3])
|
|
313
|
+
expect(await p2).toBeUndefined()
|
|
314
|
+
expect(await p3).toBeUndefined()
|
|
315
|
+
|
|
316
|
+
// Only A ever hit the API; B was superseded by the A-revert and dropped.
|
|
317
|
+
expect(calls.map((c) => c.label)).toEqual(['A'])
|
|
318
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
319
|
+
expect(gate.stats().global.dropped).toBe(1)
|
|
320
|
+
expect(gate.stats().global.coalesced).toBe(1)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('a plain repeat with NO queued edit is dropped immediately as a no-op', async () => {
|
|
324
|
+
const clock = new FakeClock()
|
|
325
|
+
const { calls, fn } = recorder(clock)
|
|
326
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
327
|
+
const msg = 12
|
|
328
|
+
|
|
329
|
+
await gate.gate(fn('A'), { messageId: msg, editPayload: 'A' })
|
|
330
|
+
await clock.advance(1500) // floor cleared, nothing queued
|
|
331
|
+
|
|
332
|
+
const r = await gate.gate(fn('A2'), { messageId: msg, editPayload: 'A' })
|
|
333
|
+
expect(r).toBeUndefined()
|
|
334
|
+
expect(calls.map((c) => c.label)).toEqual(['A'])
|
|
335
|
+
expect(gate.stats().global.dropped).toBe(1)
|
|
336
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('send-gate: stats getter', () => {
|
|
341
|
+
it('exposes counters and live bucket fill', async () => {
|
|
342
|
+
const clock = new FakeClock()
|
|
343
|
+
const { fn } = recorder(clock)
|
|
344
|
+
const gate = createSendGate({ enabled: true, clock, globalPerSec: 25, globalBurst: 25 })
|
|
345
|
+
|
|
346
|
+
await gate.gate(fn('a'), { chat_id: 'Z' })
|
|
347
|
+
const s = gate.stats()
|
|
348
|
+
expect(s.enabled).toBe(true)
|
|
349
|
+
expect(s.global.sent).toBe(1)
|
|
350
|
+
// One token consumed from a 25-capacity global bucket.
|
|
351
|
+
expect(s.fill.global).toBeCloseTo(24, 5)
|
|
352
|
+
expect(s.fill.perChat['Z']).toBeCloseTo(2, 5) // burst 3, one consumed
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('send-gate: error / rejection path (H1, M1)', () => {
|
|
357
|
+
it('rejects the failing edit and still sends a newer edit that arrived mid-flight (H1)', async () => {
|
|
358
|
+
const clock = new FakeClock()
|
|
359
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
360
|
+
const msg = 100
|
|
361
|
+
|
|
362
|
+
// First edit sends immediately (no prior edit) then suspends on the network
|
|
363
|
+
// await — a controllable deferred stands in for the in-flight send.
|
|
364
|
+
let rejectV1!: (e: unknown) => void
|
|
365
|
+
const v1Fn = () => new Promise<string>((_res, rej) => (rejectV1 = rej))
|
|
366
|
+
const p1 = gate.gate(v1Fn, { messageId: msg, editPayload: 'v1' })
|
|
367
|
+
await flush() // v1 admitted, fn() called, now suspended in-flight
|
|
368
|
+
|
|
369
|
+
// A DISTINCT edit arrives while v1 is in-flight → must coalesce, not fire.
|
|
370
|
+
const v2Calls: number[] = []
|
|
371
|
+
const v2Fn = async () => {
|
|
372
|
+
v2Calls.push(clock.now())
|
|
373
|
+
return 'v2-result'
|
|
374
|
+
}
|
|
375
|
+
const p2 = gate.gate(v2Fn, { messageId: msg, editPayload: 'v2' })
|
|
376
|
+
await flush()
|
|
377
|
+
expect(v2Calls.length).toBe(0) // v2 has NOT fired in parallel
|
|
378
|
+
|
|
379
|
+
// v1's send rejects — its OWN promise must reject (never orphaned onto v2).
|
|
380
|
+
const err = new Error('boom')
|
|
381
|
+
rejectV1(err)
|
|
382
|
+
await expect(p1).rejects.toBe(err)
|
|
383
|
+
|
|
384
|
+
// After the floor, v2 fires exactly once and resolves with v2's own result.
|
|
385
|
+
await clock.advance(1500)
|
|
386
|
+
await expect(p2).resolves.toBe('v2-result')
|
|
387
|
+
expect(v2Calls.length).toBe(1)
|
|
388
|
+
expect(gate.stats().global.sent).toBe(1) // only v2 counted as sent (v1 failed)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('retries an identical payload after a FAILED edit (M1)', async () => {
|
|
392
|
+
const clock = new FakeClock()
|
|
393
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
394
|
+
const msg = 200
|
|
395
|
+
|
|
396
|
+
const fail = async () => {
|
|
397
|
+
throw new Error('nope')
|
|
398
|
+
}
|
|
399
|
+
await expect(gate.gate(fail, { messageId: msg, editPayload: 'X' })).rejects.toThrow('nope')
|
|
400
|
+
|
|
401
|
+
await clock.advance(1500) // clear the floor
|
|
402
|
+
|
|
403
|
+
// Identical payload retry MUST hit the API — a failed send never records
|
|
404
|
+
// lastHash, so it is not dropped as a phantom no-op.
|
|
405
|
+
const sentAt: number[] = []
|
|
406
|
+
const ok = async () => {
|
|
407
|
+
sentAt.push(clock.now())
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
const r = await gate.gate(ok, { messageId: msg, editPayload: 'X' })
|
|
411
|
+
expect(r).toBe(true)
|
|
412
|
+
expect(sentAt.length).toBe(1)
|
|
413
|
+
expect(gate.stats().global.dropped).toBe(0)
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
describe('send-gate: non-edit send stats (N4)', () => {
|
|
418
|
+
it('does NOT count a throwing non-edit send as sent', async () => {
|
|
419
|
+
const clock = new FakeClock()
|
|
420
|
+
const gate = createSendGate({ enabled: true, clock })
|
|
421
|
+
|
|
422
|
+
await expect(
|
|
423
|
+
gate.gate(async () => {
|
|
424
|
+
throw new Error('send failed')
|
|
425
|
+
}),
|
|
426
|
+
).rejects.toThrow('send failed')
|
|
427
|
+
|
|
428
|
+
// The send threw → it must not pollute the `sent` counter.
|
|
429
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
430
|
+
|
|
431
|
+
// A subsequent SUCCESSFUL send is counted normally.
|
|
432
|
+
await gate.gate(async () => 'ok')
|
|
433
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe('send-gate: per-message serialization (M3, M4)', () => {
|
|
438
|
+
it('serializes two same-tick edits: one now, one after the floor (M3)', async () => {
|
|
439
|
+
const clock = new FakeClock()
|
|
440
|
+
const { calls, fn } = recorder(clock)
|
|
441
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
442
|
+
const msg = 300
|
|
443
|
+
|
|
444
|
+
const pa = gate.gate(fn('a'), { messageId: msg, editPayload: 'a' })
|
|
445
|
+
const pb = gate.gate(fn('b'), { messageId: msg, editPayload: 'b' }) // same tick
|
|
446
|
+
await flush()
|
|
447
|
+
|
|
448
|
+
// Only ONE edit fired inside the floor — no same-tick double send.
|
|
449
|
+
expect(calls.map((c) => c.label)).toEqual(['a'])
|
|
450
|
+
|
|
451
|
+
await clock.advance(1500)
|
|
452
|
+
await Promise.all([pa, pb])
|
|
453
|
+
expect(calls.map((c) => c.label)).toEqual(['a', 'b'])
|
|
454
|
+
expect(calls.find((c) => c.label === 'b')?.at).toBe(1500)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('coalesces edits behind an in-flight send sleeping on retry_after (M4)', async () => {
|
|
458
|
+
const clock = new FakeClock()
|
|
459
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
460
|
+
const msg = 400
|
|
461
|
+
|
|
462
|
+
// The in-flight send simulates robustApiCall sleeping on a 429 retry_after
|
|
463
|
+
// INSIDE the wrapped fn.
|
|
464
|
+
const started: number[] = []
|
|
465
|
+
const v1 = async () => {
|
|
466
|
+
started.push(clock.now())
|
|
467
|
+
await clock.sleep(5000)
|
|
468
|
+
return 'v1'
|
|
469
|
+
}
|
|
470
|
+
const p1 = gate.gate(v1, { messageId: msg, editPayload: 'v1' })
|
|
471
|
+
await flush()
|
|
472
|
+
expect(started.length).toBe(1) // v1 in-flight, sleeping on retry_after
|
|
473
|
+
|
|
474
|
+
// Two edits arrive while v1 sleeps → must coalesce into ONE follow-up.
|
|
475
|
+
const seen: string[] = []
|
|
476
|
+
const mk = (label: string) => async () => {
|
|
477
|
+
seen.push(label)
|
|
478
|
+
return label
|
|
479
|
+
}
|
|
480
|
+
const p2 = gate.gate(mk('v2'), { messageId: msg, editPayload: 'v2' })
|
|
481
|
+
const p3 = gate.gate(mk('v3'), { messageId: msg, editPayload: 'v3' })
|
|
482
|
+
await flush()
|
|
483
|
+
expect(seen).toEqual([]) // nothing fired in parallel with the in-flight send
|
|
484
|
+
|
|
485
|
+
await clock.advance(5000) // v1's retry_after elapses → v1 resolves
|
|
486
|
+
await p1
|
|
487
|
+
await Promise.all([p2, p3])
|
|
488
|
+
|
|
489
|
+
// Exactly ONE follow-up send carrying the LAST payload (v3); v2 coalesced.
|
|
490
|
+
expect(seen).toEqual(['v3'])
|
|
491
|
+
expect(gate.stats().global.coalesced).toBe(1)
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
describe('send-gate: bucket burst math (M2)', () => {
|
|
496
|
+
it('bounds burst to capacity and holds sustained rate at the budget over windows', async () => {
|
|
497
|
+
const clock = new FakeClock()
|
|
498
|
+
const { calls, fn } = recorder(clock)
|
|
499
|
+
// 25/sec sustained, small burst 4 (real headroom under Telegram's ~30/s:
|
|
500
|
+
// worst-case window = 4 + 25 = 29 < 30).
|
|
501
|
+
const gate = createSendGate({
|
|
502
|
+
enabled: true,
|
|
503
|
+
clock,
|
|
504
|
+
globalPerSec: 25,
|
|
505
|
+
globalBurst: 4,
|
|
506
|
+
perChatPerSec: 1000,
|
|
507
|
+
perChatBurst: 1000,
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const ps: Promise<unknown>[] = []
|
|
511
|
+
for (let i = 0; i < 120; i++) ps.push(gate.gate(fn(String(i))))
|
|
512
|
+
await flush()
|
|
513
|
+
|
|
514
|
+
// Burst: only `globalBurst` admitted at t=0 — NOT a full window's budget.
|
|
515
|
+
expect(calls.filter((c) => c.at === 0).length).toBe(4)
|
|
516
|
+
|
|
517
|
+
await clock.advance(6000)
|
|
518
|
+
await Promise.all(ps)
|
|
519
|
+
|
|
520
|
+
const times = calls.map((c) => c.at).sort((a, b) => a - b)
|
|
521
|
+
let maxAny = 0
|
|
522
|
+
let maxSustained = 0
|
|
523
|
+
for (const t of times) {
|
|
524
|
+
const inWin = times.filter((x) => x >= t && x < t + 1000).length
|
|
525
|
+
maxAny = Math.max(maxAny, inWin)
|
|
526
|
+
if (t >= 1000) maxSustained = Math.max(maxSustained, inWin) // past the burst
|
|
527
|
+
}
|
|
528
|
+
// Any 1s window ≤ budget + burst = 29 (never ~2x the budget, and strictly
|
|
529
|
+
// UNDER Telegram's ~30/s ceiling); sustained windows hold to the budget
|
|
530
|
+
// (+1 for a fractional token straddling the edge).
|
|
531
|
+
expect(maxAny).toBeLessThanOrEqual(25 + 4)
|
|
532
|
+
expect(maxAny).toBeLessThan(30)
|
|
533
|
+
expect(maxSustained).toBeLessThanOrEqual(26)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('holds a per-group ceiling near budget/min, not ~2x (M2)', async () => {
|
|
537
|
+
const clock = new FakeClock()
|
|
538
|
+
const { calls, fn } = recorder(clock)
|
|
539
|
+
const gate = createSendGate({
|
|
540
|
+
enabled: true,
|
|
541
|
+
clock,
|
|
542
|
+
globalPerSec: 1000,
|
|
543
|
+
globalBurst: 1000,
|
|
544
|
+
perChatPerSec: 1000,
|
|
545
|
+
perChatBurst: 1000,
|
|
546
|
+
perGroupPerMin: 18,
|
|
547
|
+
perGroupBurst: 2,
|
|
548
|
+
})
|
|
549
|
+
const g = { chat_id: 'G', chatType: 'supergroup' as const }
|
|
550
|
+
|
|
551
|
+
const ps: Promise<unknown>[] = []
|
|
552
|
+
for (let i = 0; i < 50; i++) ps.push(gate.gate(fn(String(i)), g))
|
|
553
|
+
await flush()
|
|
554
|
+
|
|
555
|
+
expect(calls.filter((c) => c.at === 0).length).toBe(2) // burst 2, not 18
|
|
556
|
+
|
|
557
|
+
await clock.advance(200_000)
|
|
558
|
+
await Promise.all(ps)
|
|
559
|
+
|
|
560
|
+
const times = calls.map((c) => c.at).sort((a, b) => a - b)
|
|
561
|
+
let maxMinute = 0
|
|
562
|
+
for (const t of times) {
|
|
563
|
+
if (t < 60_000) continue // skip the burst-inclusive first minute
|
|
564
|
+
const inWin = times.filter((x) => x >= t && x < t + 60_000).length
|
|
565
|
+
maxMinute = Math.max(maxMinute, inWin)
|
|
566
|
+
}
|
|
567
|
+
expect(maxMinute).toBeLessThanOrEqual(19) // ~18/min sustained, never ~35
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
describe('send-gate: editPayload undefined (L2)', () => {
|
|
572
|
+
it('does not throw when editPayload is undefined; treats a repeat as a no-op', async () => {
|
|
573
|
+
const clock = new FakeClock()
|
|
574
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
575
|
+
const sent: number[] = []
|
|
576
|
+
const fn = async () => {
|
|
577
|
+
sent.push(1)
|
|
578
|
+
return 'ok'
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const r = await gate.gate(fn, { messageId: 7, editPayload: undefined })
|
|
582
|
+
expect(r).toBe('ok')
|
|
583
|
+
expect(sent.length).toBe(1)
|
|
584
|
+
|
|
585
|
+
await clock.advance(1500)
|
|
586
|
+
const r2 = await gate.gate(fn, { messageId: 7, editPayload: undefined })
|
|
587
|
+
expect(r2).toBeUndefined() // identical (undefined) payload → no-op skip
|
|
588
|
+
expect(gate.stats().global.dropped).toBe(1)
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('send-gate: perMessage eviction (H2)', () => {
|
|
593
|
+
it('evicts idle message states after the TTL', async () => {
|
|
594
|
+
const clock = new FakeClock()
|
|
595
|
+
const { fn } = recorder(clock)
|
|
596
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500, messageStateTtlMs: 10_000 })
|
|
597
|
+
|
|
598
|
+
await gate.gate(fn('a'), { messageId: 1, editPayload: 'a' })
|
|
599
|
+
expect(gate.stats().messageStates).toBe(1)
|
|
600
|
+
|
|
601
|
+
// Past the TTL, a touch on a DIFFERENT message triggers the sweep.
|
|
602
|
+
await clock.advance(20_000)
|
|
603
|
+
await gate.gate(fn('b'), { messageId: 2, editPayload: 'b' })
|
|
604
|
+
|
|
605
|
+
// msg 1 is idle + older than the TTL → swept; only msg 2 remains.
|
|
606
|
+
expect(gate.stats().messageStates).toBe(1)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('enforces a hard LRU cap on live message states', async () => {
|
|
610
|
+
const clock = new FakeClock()
|
|
611
|
+
const { fn } = recorder(clock)
|
|
612
|
+
const gate = createSendGate({
|
|
613
|
+
enabled: true,
|
|
614
|
+
clock,
|
|
615
|
+
editFloorMs: 1,
|
|
616
|
+
globalPerSec: 1000, // global bucket not the limiter here
|
|
617
|
+
globalBurst: 1000,
|
|
618
|
+
maxMessageStates: 3,
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
for (let i = 0; i < 10; i++) {
|
|
622
|
+
await gate.gate(fn('m' + i), { messageId: i, editPayload: 'p' + i })
|
|
623
|
+
await clock.advance(2) // clear the tiny floor so each state is idle/evictable
|
|
624
|
+
}
|
|
625
|
+
expect(gate.stats().messageStates).toBeLessThanOrEqual(3)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('opening per-message flood windows honours the LRU cap (N2)', async () => {
|
|
629
|
+
const clock = new FakeClock()
|
|
630
|
+
const gate = createSendGate({ enabled: true, clock, maxMessageStates: 3 })
|
|
631
|
+
|
|
632
|
+
// Each openFloodWindow for a distinct msg-edit scope creates a per-message
|
|
633
|
+
// state; it must go through the same eviction accounting so the map never
|
|
634
|
+
// grows past the cap. Windows are in the past so the states stay evictable.
|
|
635
|
+
for (let i = 0; i < 10; i++) gate.openFloodWindow('msg-edit:' + i, -1)
|
|
636
|
+
|
|
637
|
+
expect(gate.stats().messageStates).toBeLessThanOrEqual(3)
|
|
638
|
+
})
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
describe('send-gate: flood windows + boot ramp (L3 / §7 hook)', () => {
|
|
642
|
+
it('an open flood window blocks admission until untilTs (openFloodWindow)', async () => {
|
|
643
|
+
const clock = new FakeClock()
|
|
644
|
+
const { calls, fn } = recorder(clock)
|
|
645
|
+
const gate = createSendGate({ enabled: true, clock })
|
|
646
|
+
|
|
647
|
+
gate.openFloodWindow('global', 5000)
|
|
648
|
+
|
|
649
|
+
const p = gate.gate(fn('a'))
|
|
650
|
+
await flush()
|
|
651
|
+
expect(calls.length).toBe(0) // suppressed despite a full token bucket
|
|
652
|
+
|
|
653
|
+
await clock.advance(5000)
|
|
654
|
+
await p
|
|
655
|
+
expect(calls.map((c) => c.at)).toEqual([5000]) // fires exactly when the window clears
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('re-opens flood windows passed at construction (initialWindows)', async () => {
|
|
659
|
+
const clock = new FakeClock()
|
|
660
|
+
const { calls, fn } = recorder(clock)
|
|
661
|
+
const gate = createSendGate({
|
|
662
|
+
enabled: true,
|
|
663
|
+
clock,
|
|
664
|
+
initialWindows: [{ scopeKey: 'chat:C', untilTs: 3000 }],
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
const p = gate.gate(fn('a'), { chat_id: 'C' })
|
|
668
|
+
await flush()
|
|
669
|
+
expect(calls.length).toBe(0) // chat:C suppressed
|
|
670
|
+
|
|
671
|
+
// A different chat is unaffected by C's window.
|
|
672
|
+
await gate.gate(fn('other'), { chat_id: 'D' })
|
|
673
|
+
expect(calls.map((c) => c.label)).toEqual(['other'])
|
|
674
|
+
|
|
675
|
+
await clock.advance(3000)
|
|
676
|
+
await p
|
|
677
|
+
expect(calls.find((c) => c.label === 'a')?.at).toBe(3000)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('starts the global bucket at a fraction of capacity during the boot ramp', async () => {
|
|
681
|
+
const clock = new FakeClock()
|
|
682
|
+
const { calls, fn } = recorder(clock)
|
|
683
|
+
// Burst 10, but ramped to 50% (=5) for the first 10s.
|
|
684
|
+
const gate = createSendGate({
|
|
685
|
+
enabled: true,
|
|
686
|
+
clock,
|
|
687
|
+
globalPerSec: 1000, // rate not the limiter; isolate the ramped burst
|
|
688
|
+
globalBurst: 10,
|
|
689
|
+
bootRamp: { fraction: 0.5, durationMs: 10_000 },
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const ps: Promise<unknown>[] = []
|
|
693
|
+
for (let i = 0; i < 10; i++) ps.push(gate.gate(fn(String(i))))
|
|
694
|
+
await flush()
|
|
695
|
+
// Only the ramped burst (5 = 50% of 10) admits immediately at boot.
|
|
696
|
+
expect(calls.filter((c) => c.at === 0).length).toBe(5)
|
|
697
|
+
|
|
698
|
+
await clock.advance(11_000)
|
|
699
|
+
await Promise.all(ps)
|
|
700
|
+
|
|
701
|
+
// After the ramp window, the full burst capacity is available again.
|
|
702
|
+
const gate2 = createSendGate({
|
|
703
|
+
enabled: true,
|
|
704
|
+
clock,
|
|
705
|
+
globalPerSec: 1000,
|
|
706
|
+
globalBurst: 10,
|
|
707
|
+
// ramp already elapsed relative to this clock's now (11_000 > 10_000)
|
|
708
|
+
bootRamp: { fraction: 0.5, durationMs: 0 },
|
|
709
|
+
})
|
|
710
|
+
const { calls: c2, fn: fn2 } = recorder(clock)
|
|
711
|
+
const ps2: Promise<unknown>[] = []
|
|
712
|
+
for (let i = 0; i < 10; i++) ps2.push(gate2.gate(fn2(String(i))))
|
|
713
|
+
await flush()
|
|
714
|
+
await Promise.all(ps2)
|
|
715
|
+
expect(c2.filter((c) => c.at === 11_000).length).toBe(10) // full burst, no ramp
|
|
716
|
+
})
|
|
717
|
+
})
|