switchroom 0.18.17 → 0.18.19
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 +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -352,6 +352,146 @@ describe('createFloodWindowObserver — alerting', () => {
|
|
|
352
352
|
expect(alerts).toHaveLength(1)
|
|
353
353
|
})
|
|
354
354
|
|
|
355
|
+
// The operator-facing alert timestamps must render in the CONFIGURED local
|
|
356
|
+
// timezone, not UTC. The live incident that prompted this: a "flood ban
|
|
357
|
+
// cleared" card printed `2026-07-12T23:39:24Z to 2026-07-12T23:46:40Z UTC`,
|
|
358
|
+
// which is 9:39am–9:46am AEST in the fleet's configured zone. These assert
|
|
359
|
+
// the local rendering and would FAIL on the old `.toISOString()` output.
|
|
360
|
+
const OBSERVED = Date.parse('2026-07-12T23:39:24Z') // 2026-07-13 09:39 AEST
|
|
361
|
+
const UNTIL = Date.parse('2026-07-12T23:46:40Z') // 2026-07-13 09:46 AEST
|
|
362
|
+
|
|
363
|
+
it('renders the CLEARED card timestamps in the configured local tz (AEST), not UTC', async () => {
|
|
364
|
+
const clock = new TestClock()
|
|
365
|
+
const alerts: string[] = []
|
|
366
|
+
let windows: FloodWindowRecord[] = [
|
|
367
|
+
{ scopeKey: 'global', untilTs: UNTIL, retryAfterSrc: '429', observedAt: OBSERVED },
|
|
368
|
+
]
|
|
369
|
+
const obs = createFloodWindowObserver({
|
|
370
|
+
clock,
|
|
371
|
+
log: () => {},
|
|
372
|
+
stats: () => makeStats(),
|
|
373
|
+
readWindows: () => windows,
|
|
374
|
+
markAlerted: () => {},
|
|
375
|
+
sendAlert: async (t) => {
|
|
376
|
+
alerts.push(t)
|
|
377
|
+
},
|
|
378
|
+
operatorChatId: () => 'op',
|
|
379
|
+
alertThresholdMs: 60_000,
|
|
380
|
+
tz: 'Australia/Melbourne',
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Past threshold but global → operator unreachable → defer to close.
|
|
384
|
+
clock.cur = OBSERVED + 61_000
|
|
385
|
+
await obs.tick()
|
|
386
|
+
expect(alerts).toHaveLength(0)
|
|
387
|
+
|
|
388
|
+
// Window closes → deferred cleared card fires, in LOCAL time.
|
|
389
|
+
clock.cur = UNTIL + 1_000
|
|
390
|
+
windows = []
|
|
391
|
+
await obs.tick()
|
|
392
|
+
expect(alerts).toHaveLength(1)
|
|
393
|
+
expect(alerts[0]).toContain('flood ban cleared')
|
|
394
|
+
// Local-tz rendering: 9:39am to 9:46am AEST.
|
|
395
|
+
expect(alerts[0]).toContain('was banned from 9:39am to 9:46am AEST')
|
|
396
|
+
// And explicitly NOT the old UTC ISO output.
|
|
397
|
+
expect(alerts[0]).not.toContain('2026-07-12T23:39:24Z')
|
|
398
|
+
expect(alerts[0]).not.toContain('UTC')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('renders the ACTIVE card clear-time in the configured local tz (AEST), not UTC', async () => {
|
|
402
|
+
const clock = new TestClock()
|
|
403
|
+
const alerts: string[] = []
|
|
404
|
+
let windows: FloodWindowRecord[] = [
|
|
405
|
+
// A far-future untilTs so the window stays open; a non-operator chat so
|
|
406
|
+
// the immediate-delivery branch fires (openAlertText).
|
|
407
|
+
{ scopeKey: 'chat:other', untilTs: UNTIL, retryAfterSrc: '429', observedAt: OBSERVED },
|
|
408
|
+
]
|
|
409
|
+
const obs = createFloodWindowObserver({
|
|
410
|
+
clock,
|
|
411
|
+
log: () => {},
|
|
412
|
+
stats: () => makeStats(),
|
|
413
|
+
readWindows: () => windows,
|
|
414
|
+
markAlerted: (scope, at) => {
|
|
415
|
+
windows = windows.map((w) => (w.scopeKey === scope ? { ...w, alertedAt: at } : w))
|
|
416
|
+
},
|
|
417
|
+
sendAlert: async (t) => {
|
|
418
|
+
alerts.push(t)
|
|
419
|
+
},
|
|
420
|
+
operatorChatId: () => 'op',
|
|
421
|
+
alertThresholdMs: 60_000,
|
|
422
|
+
tz: 'Australia/Melbourne',
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
clock.cur = OBSERVED + 61_000
|
|
426
|
+
await obs.tick()
|
|
427
|
+
expect(alerts).toHaveLength(1)
|
|
428
|
+
expect(alerts[0]).toContain('flood ban active')
|
|
429
|
+
// "expected to clear at 9:46am AEST" — local wall-clock, not UTC ISO.
|
|
430
|
+
expect(alerts[0]).toContain('expected to clear at 9:46am AEST')
|
|
431
|
+
expect(alerts[0]).not.toContain('2026-07-12T23:46:40Z')
|
|
432
|
+
expect(alerts[0]).not.toContain('UTC')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('defaults to UTC wording when no tz is configured (backward compat)', async () => {
|
|
436
|
+
const clock = new TestClock()
|
|
437
|
+
const alerts: string[] = []
|
|
438
|
+
let windows: FloodWindowRecord[] = [
|
|
439
|
+
{ scopeKey: 'global', untilTs: UNTIL, retryAfterSrc: '429', observedAt: OBSERVED },
|
|
440
|
+
]
|
|
441
|
+
const obs = createFloodWindowObserver({
|
|
442
|
+
clock,
|
|
443
|
+
log: () => {},
|
|
444
|
+
stats: () => makeStats(),
|
|
445
|
+
readWindows: () => windows,
|
|
446
|
+
markAlerted: () => {},
|
|
447
|
+
sendAlert: async (t) => {
|
|
448
|
+
alerts.push(t)
|
|
449
|
+
},
|
|
450
|
+
operatorChatId: () => 'op',
|
|
451
|
+
alertThresholdMs: 60_000,
|
|
452
|
+
// no tz → UTC
|
|
453
|
+
})
|
|
454
|
+
clock.cur = OBSERVED + 61_000
|
|
455
|
+
await obs.tick()
|
|
456
|
+
clock.cur = UNTIL + 1_000
|
|
457
|
+
windows = []
|
|
458
|
+
await obs.tick()
|
|
459
|
+
expect(alerts).toHaveLength(1)
|
|
460
|
+
// UTC fallback: 11:39pm to 11:46pm UTC on 2026-07-12.
|
|
461
|
+
expect(alerts[0]).toContain('11:39pm to 11:46pm UTC')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('spans the local date when a window crosses local midnight', async () => {
|
|
465
|
+
const clock = new TestClock()
|
|
466
|
+
const alerts: string[] = []
|
|
467
|
+
// 2026-07-13T13:55:00Z .. 2026-07-13T14:05:00Z = 11:55pm 13 Jul .. 12:05am 14 Jul AEST.
|
|
468
|
+
const obsStart = Date.parse('2026-07-13T13:55:00Z')
|
|
469
|
+
const obsEnd = Date.parse('2026-07-13T14:05:00Z')
|
|
470
|
+
let windows: FloodWindowRecord[] = [
|
|
471
|
+
{ scopeKey: 'global', untilTs: obsEnd, retryAfterSrc: '429', observedAt: obsStart },
|
|
472
|
+
]
|
|
473
|
+
const obs = createFloodWindowObserver({
|
|
474
|
+
clock,
|
|
475
|
+
log: () => {},
|
|
476
|
+
stats: () => makeStats(),
|
|
477
|
+
readWindows: () => windows,
|
|
478
|
+
markAlerted: () => {},
|
|
479
|
+
sendAlert: async (t) => {
|
|
480
|
+
alerts.push(t)
|
|
481
|
+
},
|
|
482
|
+
operatorChatId: () => 'op',
|
|
483
|
+
alertThresholdMs: 60_000,
|
|
484
|
+
tz: 'Australia/Melbourne',
|
|
485
|
+
})
|
|
486
|
+
clock.cur = obsStart + 61_000
|
|
487
|
+
await obs.tick()
|
|
488
|
+
clock.cur = obsEnd + 1_000
|
|
489
|
+
windows = []
|
|
490
|
+
await obs.tick()
|
|
491
|
+
expect(alerts).toHaveLength(1)
|
|
492
|
+
expect(alerts[0]).toContain('13 Jul 11:55pm to 14 Jul 12:05am AEST')
|
|
493
|
+
})
|
|
494
|
+
|
|
355
495
|
it('does not alert for msg-edit (cosmetic) scopes', async () => {
|
|
356
496
|
const clock = new TestClock()
|
|
357
497
|
const alerts: string[] = []
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
* "was banned from X to Y" alert when the window CLOSES (or as soon as the
|
|
30
30
|
* operator chat becomes reachable again).
|
|
31
31
|
*
|
|
32
|
-
* STATUS OF THE IMMEDIATE PATH (
|
|
33
|
-
*
|
|
34
|
-
* The immediate-delivery branch is present
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
32
|
+
* STATUS OF THE IMMEDIATE PATH (live since #3111)
|
|
33
|
+
* -----------------------------------------------
|
|
34
|
+
* The immediate-delivery branch is present, unit-tested, AND now fires in
|
|
35
|
+
* production: as of #3111 a chat-scoped 429 opens ONLY that chat's window (no
|
|
36
|
+
* coincident `global`), so when a ban covers `chat:<other>` while the operator's
|
|
37
|
+
* own chat is clear, the alert is delivered IMMEDIATELY rather than deferred to
|
|
38
|
+
* close. A genuinely global 429 still opens `global`, so the operator chat is
|
|
39
|
+
* "covered" and the alert defers to close — once per incident, all scopes
|
|
40
|
+
* coalesced into one card (M1, #3112).
|
|
40
41
|
*
|
|
41
42
|
* At-most-once is anchored by the persisted `alertedAt` (survives restart). The
|
|
42
43
|
* deferred close-alert is best-effort at-least-once: if the gateway is down for
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
|
|
54
55
|
import type { Clock, SendGateStats } from './send-gate.js'
|
|
55
56
|
import type { FloodWindowRecord } from './flood-circuit-breaker.js'
|
|
57
|
+
import { fmtLocalClock, fmtLocalDate, localDay, tzAbbrev } from './shared/local-time.js'
|
|
56
58
|
|
|
57
59
|
/** One-line, human-scannable summary of the gate counters + global fill. */
|
|
58
60
|
export function formatStatsLine(stats: SendGateStats): string {
|
|
@@ -153,6 +155,14 @@ export interface FloodWindowObserverConfig {
|
|
|
153
155
|
operatorChatId?: () => string | undefined
|
|
154
156
|
/** Ms a window must be open before it earns an alert. Default 60_000. */
|
|
155
157
|
alertThresholdMs?: number
|
|
158
|
+
/**
|
|
159
|
+
* IANA timezone (e.g. `Australia/Melbourne`) for rendering the operator-facing
|
|
160
|
+
* alert timestamps in local wall-clock time instead of UTC. The gateway sources
|
|
161
|
+
* this from `SWITCHROOM_TIMEZONE ?? TZ` (the same env the config cascade bakes
|
|
162
|
+
* into every agent service — see `src/config/timezone.ts`). Defaults to `'UTC'`
|
|
163
|
+
* so a missing tz degrades to the previous behaviour rather than throwing.
|
|
164
|
+
*/
|
|
165
|
+
tz?: string
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
export interface FloodWindowObserver {
|
|
@@ -160,11 +170,44 @@ export interface FloodWindowObserver {
|
|
|
160
170
|
tick(): Promise<void>
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
// ─── Operator-facing local-time formatting ──────────────────────────────────
|
|
174
|
+
// Operator alerts (openAlertText / closeAlertText) render timestamps in the
|
|
175
|
+
// operator's CONFIGURED timezone, not UTC — a "was banned from 9:39am to 9:46am
|
|
176
|
+
// AEST" reads at a glance where a raw `2026-07-12T23:39:24Z UTC` does not. The
|
|
177
|
+
// machine-facing snapshot LOG lines (config.log → gateway-supervisor.log) keep
|
|
178
|
+
// their epoch/ISO wording; only the chat/card text is localized.
|
|
179
|
+
//
|
|
180
|
+
// The four wall-clock primitives (fmtLocalClock / fmtLocalDate / localDay /
|
|
181
|
+
// tzAbbrev) now live in shared/local-time.ts so the humanized LLM-error card
|
|
182
|
+
// renders timestamps through the SAME source of truth. The two composers below
|
|
183
|
+
// (fmtLocalStamp / fmtLocalRange) stay here — they are this module's own wording.
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A single operator-facing local timestamp with tz suffix, e.g. `9:46am AEST`.
|
|
187
|
+
* When `refMs` is on a different local calendar day (or omitted), the date is
|
|
188
|
+
* prepended (`12 Jul 11:52pm AEST`) so a day-spanning window is unambiguous.
|
|
189
|
+
*/
|
|
190
|
+
function fmtLocalStamp(ms: number, tz: string, refMs?: number): string {
|
|
191
|
+
const clock = fmtLocalClock(ms, tz)
|
|
192
|
+
const sameDay = refMs != null && localDay(ms, tz) === localDay(refMs, tz)
|
|
193
|
+
const body = sameDay ? clock : `${fmtLocalDate(ms, tz)} ${clock}`
|
|
194
|
+
return `${body} ${tzAbbrev(ms, tz)}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A local time RANGE with a single tz suffix, e.g. `9:39am to 9:46am AEST`.
|
|
199
|
+
* Same-local-day windows drop the redundant start date; day-spanning windows
|
|
200
|
+
* carry the date on each end (`12 Jul 11:59pm to 13 Jul 12:04am AEST`).
|
|
201
|
+
*/
|
|
202
|
+
function fmtLocalRange(startMs: number, endMs: number, tz: string): string {
|
|
203
|
+
const sameDay = localDay(startMs, tz) === localDay(endMs, tz)
|
|
204
|
+
if (sameDay) {
|
|
205
|
+
return `${fmtLocalClock(startMs, tz)} to ${fmtLocalClock(endMs, tz)} ${tzAbbrev(endMs, tz)}`
|
|
206
|
+
}
|
|
207
|
+
return (
|
|
208
|
+
`${fmtLocalDate(startMs, tz)} ${fmtLocalClock(startMs, tz)} to ` +
|
|
209
|
+
`${fmtLocalDate(endMs, tz)} ${fmtLocalClock(endMs, tz)} ${tzAbbrev(endMs, tz)}`
|
|
210
|
+
)
|
|
168
211
|
}
|
|
169
212
|
|
|
170
213
|
function fmtDur(ms: number): string {
|
|
@@ -186,6 +229,7 @@ export function createFloodWindowObserver(
|
|
|
186
229
|
config: FloodWindowObserverConfig,
|
|
187
230
|
): FloodWindowObserver {
|
|
188
231
|
const alertThresholdMs = config.alertThresholdMs ?? 60_000
|
|
232
|
+
const tz = config.tz ?? 'UTC'
|
|
189
233
|
// Full records seen on the previous tick, keyed by scope (close detection).
|
|
190
234
|
let lastSeen = new Map<string, FloodWindowRecord>()
|
|
191
235
|
// First tick after boot: any window already present was loaded from disk
|
|
@@ -232,7 +276,7 @@ export function createFloodWindowObserver(
|
|
|
232
276
|
const openFor = fmtDur(now - w.observedAt)
|
|
233
277
|
return (
|
|
234
278
|
`⚠️ Telegram flood ban active (scope \`${w.scopeKey}\`). ` +
|
|
235
|
-
`Open for ${openFor}, expected to clear at ${
|
|
279
|
+
`Open for ${openFor}, expected to clear at ${fmtLocalStamp(w.untilTs, tz, now)}. ` +
|
|
236
280
|
`Outbound to that scope is being suppressed.`
|
|
237
281
|
)
|
|
238
282
|
}
|
|
@@ -250,7 +294,7 @@ export function createFloodWindowObserver(
|
|
|
250
294
|
const plural = recs.length > 1 ? 's' : ''
|
|
251
295
|
return (
|
|
252
296
|
`⚠️ Telegram flood ban cleared (scope${plural} ${scopes}). ` +
|
|
253
|
-
`The bot was banned from ${
|
|
297
|
+
`The bot was banned from ${fmtLocalRange(observedAt, untilTs, tz)} ` +
|
|
254
298
|
`(~${fmtDur(untilTs - observedAt)}). Some outbound messages during that ` +
|
|
255
299
|
`window were suppressed.`
|
|
256
300
|
)
|
|
@@ -312,11 +356,12 @@ export function createFloodWindowObserver(
|
|
|
312
356
|
if (w.alertedAt != null) continue
|
|
313
357
|
if (now - w.observedAt < alertThresholdMs) continue
|
|
314
358
|
if (operatorReachable && !coversOperator(w, operatorChatId)) {
|
|
315
|
-
// NOTE (M1, #3112): this immediate-delivery branch is
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
359
|
+
// NOTE (M1, #3112 / #3111): this immediate-delivery branch is now LIVE
|
|
360
|
+
// for recorder-produced state. Since #3111 a chat-scoped 429 opens only
|
|
361
|
+
// that chat's window (no coincident `global`), so when a ban covers a
|
|
362
|
+
// chat OTHER than the operator's, `operatorReachable` is true and the
|
|
363
|
+
// alert is delivered here rather than deferred to close. A genuinely
|
|
364
|
+
// global 429 still opens `global`, keeping the operator "covered".
|
|
320
365
|
// NOTE (L1, #3112): send-first, then persist `alertedAt`. A crash between
|
|
321
366
|
// the delivered card and the disk write re-alerts on restart — a benign
|
|
322
367
|
// duplicate. Deliberate: send-first guarantees an alert is never LOST,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createSendGate,
|
|
4
|
+
sendGateEnabledFromEnv,
|
|
5
|
+
sendGateConfigFromEnv,
|
|
6
|
+
SEND_GATE_DEFAULTS,
|
|
7
|
+
type Clock,
|
|
8
|
+
} from './send-gate.js'
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Deterministic fake clock. `sleep()` registers a wake at `now + ms`;
|
|
@@ -117,6 +123,142 @@ describe('send-gate: feature flag', () => {
|
|
|
117
123
|
})
|
|
118
124
|
})
|
|
119
125
|
|
|
126
|
+
describe('send-gate: SEND_GATE_DEFAULTS (compile-time default PIN)', () => {
|
|
127
|
+
it('pins the exact default rate limits — a drive-by change fails here', () => {
|
|
128
|
+
// Load-bearing PIN: these are the values createSendGate falls back to when a
|
|
129
|
+
// knob is unset, i.e. today's shipped behaviour. If any of these numbers
|
|
130
|
+
// changes, that is a real behaviour change and must be a deliberate,
|
|
131
|
+
// reviewed edit — not a silent drift. (#3084 send-gate config PR.)
|
|
132
|
+
expect(SEND_GATE_DEFAULTS).toEqual({
|
|
133
|
+
globalPerSec: 25,
|
|
134
|
+
globalBurst: 4,
|
|
135
|
+
perChatPerSec: 1,
|
|
136
|
+
perChatBurst: 3,
|
|
137
|
+
perGroupPerMin: 18,
|
|
138
|
+
perGroupBurst: 2,
|
|
139
|
+
editFloorMs: 1500,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('send-gate: sendGateConfigFromEnv (yaml → env → createSendGate)', () => {
|
|
145
|
+
const E = (o: Record<string, string | undefined>) => o as unknown as NodeJS.ProcessEnv
|
|
146
|
+
|
|
147
|
+
it('maps every SWITCHROOM_TG_SEND_GATE_* env to the exact createSendGate field', () => {
|
|
148
|
+
// The gateway spreads this object straight into createSendGate({...}), so
|
|
149
|
+
// asserting the returned slice IS asserting what createSendGate receives.
|
|
150
|
+
const cfg = sendGateConfigFromEnv(
|
|
151
|
+
E({
|
|
152
|
+
SWITCHROOM_TG_SEND_GATE_ENABLED: '1',
|
|
153
|
+
SWITCHROOM_TG_SEND_GATE_GLOBAL_PER_SEC: '40',
|
|
154
|
+
SWITCHROOM_TG_SEND_GATE_GLOBAL_BURST: '6',
|
|
155
|
+
SWITCHROOM_TG_SEND_GATE_PER_CHAT_PER_SEC: '2',
|
|
156
|
+
SWITCHROOM_TG_SEND_GATE_PER_CHAT_BURST: '5',
|
|
157
|
+
SWITCHROOM_TG_SEND_GATE_PER_GROUP_PER_MIN: '30',
|
|
158
|
+
SWITCHROOM_TG_SEND_GATE_PER_GROUP_BURST: '4',
|
|
159
|
+
SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS: '2000',
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
expect(cfg).toEqual({
|
|
163
|
+
enabled: true,
|
|
164
|
+
globalPerSec: 40,
|
|
165
|
+
globalBurst: 6,
|
|
166
|
+
perChatPerSec: 2,
|
|
167
|
+
perChatBurst: 5,
|
|
168
|
+
perGroupPerMin: 30,
|
|
169
|
+
perGroupBurst: 4,
|
|
170
|
+
editFloorMs: 2000,
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('accepts a fractional per-sec rate (rates are not integers)', () => {
|
|
175
|
+
const cfg = sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_PER_CHAT_PER_SEC: '0.5' }))
|
|
176
|
+
expect(cfg.perChatPerSec).toBe(0.5)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('unset knobs are ABSENT so createSendGate applies SEND_GATE_DEFAULTS (no behaviour change)', () => {
|
|
180
|
+
const cfg = sendGateConfigFromEnv(E({}))
|
|
181
|
+
// Only enabled is present; every rate field is omitted → default fallback.
|
|
182
|
+
expect(cfg).toEqual({ enabled: true })
|
|
183
|
+
// And the gate built from it actually pours in the pinned defaults.
|
|
184
|
+
const gate = createSendGate({ ...cfg, clock: new FakeClock() })
|
|
185
|
+
// enabled default is ON (escape hatch, not opt-in).
|
|
186
|
+
expect(gate.stats().enabled).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('drops malformed / wedge-inducing env values back to the default (defensive net)', () => {
|
|
190
|
+
// The LOUD rejection lives in the Zod config schema; a hand-set bad env must
|
|
191
|
+
// never wedge sends, so it falls back to undefined → compile-time default.
|
|
192
|
+
const cfg = sendGateConfigFromEnv(
|
|
193
|
+
E({
|
|
194
|
+
SWITCHROOM_TG_SEND_GATE_GLOBAL_PER_SEC: '0', // ≤0 → dropped
|
|
195
|
+
SWITCHROOM_TG_SEND_GATE_PER_CHAT_BURST: '-3', // negative → dropped
|
|
196
|
+
SWITCHROOM_TG_SEND_GATE_PER_GROUP_BURST: '2.5', // non-int → dropped
|
|
197
|
+
SWITCHROOM_TG_SEND_GATE_GLOBAL_BURST: 'nonsense', // NaN → dropped
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
expect(cfg).toEqual({ enabled: true })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('edit_floor_ms accepts 0 (disables the floor) but not negatives', () => {
|
|
204
|
+
expect(sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS: '0' })).editFloorMs).toBe(0)
|
|
205
|
+
expect(
|
|
206
|
+
sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS: '-1' })).editFloorMs,
|
|
207
|
+
).toBeUndefined()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('maps the #3111 conservative-global flood-scope break-glass flag', () => {
|
|
211
|
+
// Unset ⇒ absent (scope-precise default; createSendGate falls back to false).
|
|
212
|
+
expect(
|
|
213
|
+
sendGateConfigFromEnv(E({})).conservativeGlobalFloodScope,
|
|
214
|
+
).toBeUndefined()
|
|
215
|
+
// On-values ⇒ true (restore pre-#3111 always-open-global posture).
|
|
216
|
+
for (const on of ['1', 'true', 'on', 'yes']) {
|
|
217
|
+
expect(
|
|
218
|
+
sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL: on }))
|
|
219
|
+
.conservativeGlobalFloodScope,
|
|
220
|
+
).toBe(true)
|
|
221
|
+
}
|
|
222
|
+
// Off-values ⇒ explicit false.
|
|
223
|
+
expect(
|
|
224
|
+
sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL: '0' }))
|
|
225
|
+
.conservativeGlobalFloodScope,
|
|
226
|
+
).toBe(false)
|
|
227
|
+
// Unrecognised ⇒ absent (defensive net).
|
|
228
|
+
expect(
|
|
229
|
+
sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL: 'nonsense' }))
|
|
230
|
+
.conservativeGlobalFloodScope,
|
|
231
|
+
).toBeUndefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('enabled precedence: break-glass valve > config > default-on', () => {
|
|
235
|
+
it('config send_gate.enabled decides when the env valve is unset', () => {
|
|
236
|
+
expect(sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_ENABLED: '0' })).enabled).toBe(false)
|
|
237
|
+
expect(sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_ENABLED: '1' })).enabled).toBe(true)
|
|
238
|
+
expect(sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_ENABLED: 'off' })).enabled).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('the SWITCHROOM_TELEGRAM_SEND_GATE valve ALWAYS wins when explicitly set', () => {
|
|
242
|
+
// valve off beats config on
|
|
243
|
+
expect(
|
|
244
|
+
sendGateConfigFromEnv(
|
|
245
|
+
E({ SWITCHROOM_TELEGRAM_SEND_GATE: '0', SWITCHROOM_TG_SEND_GATE_ENABLED: '1' }),
|
|
246
|
+
).enabled,
|
|
247
|
+
).toBe(false)
|
|
248
|
+
// valve on beats config off
|
|
249
|
+
expect(
|
|
250
|
+
sendGateConfigFromEnv(
|
|
251
|
+
E({ SWITCHROOM_TELEGRAM_SEND_GATE: '1', SWITCHROOM_TG_SEND_GATE_ENABLED: '0' }),
|
|
252
|
+
).enabled,
|
|
253
|
+
).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('both unset ⇒ enabled (ON by default, unchanged)', () => {
|
|
257
|
+
expect(sendGateConfigFromEnv(E({})).enabled).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
120
262
|
describe('send-gate: global bucket', () => {
|
|
121
263
|
it('admits up to capacity immediately, then paces the rest by refill', async () => {
|
|
122
264
|
const clock = new FakeClock()
|