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,470 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
formatStatsLine,
|
|
7
|
+
countersChanged,
|
|
8
|
+
createStatsLogger,
|
|
9
|
+
createFloodWindowObserver,
|
|
10
|
+
} from './send-gate-observability.js'
|
|
11
|
+
import type { SendGateStats, Clock } from './send-gate.js'
|
|
12
|
+
import {
|
|
13
|
+
writeFloodWindow,
|
|
14
|
+
readFloodWindows,
|
|
15
|
+
markFloodWindowAlerted,
|
|
16
|
+
floodWindowsPath,
|
|
17
|
+
type FloodWindowRecord,
|
|
18
|
+
} from './flood-circuit-breaker.js'
|
|
19
|
+
import { statusPairedText } from './welcome-text.js'
|
|
20
|
+
|
|
21
|
+
/** Minimal settable clock. Observer/logger never actually sleep in these tests. */
|
|
22
|
+
class TestClock implements Clock {
|
|
23
|
+
cur = 0
|
|
24
|
+
now(): number {
|
|
25
|
+
return this.cur
|
|
26
|
+
}
|
|
27
|
+
async sleep(): Promise<void> {
|
|
28
|
+
/* unused by the observability tick loops */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeStats(over: Partial<SendGateStats['global']> = {}, enabled = true): SendGateStats {
|
|
33
|
+
return {
|
|
34
|
+
enabled,
|
|
35
|
+
global: {
|
|
36
|
+
sent: 0,
|
|
37
|
+
queued: 0,
|
|
38
|
+
coalesced: 0,
|
|
39
|
+
dropped: 0,
|
|
40
|
+
shed: 0,
|
|
41
|
+
expired: 0,
|
|
42
|
+
failedFast: 0,
|
|
43
|
+
...over,
|
|
44
|
+
},
|
|
45
|
+
messageStates: 0,
|
|
46
|
+
fill: { global: 4, perChat: {}, perGroup: {} },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('formatStatsLine', () => {
|
|
51
|
+
it('renders every counter + fill on one trailing-newline line', () => {
|
|
52
|
+
const line = formatStatsLine(
|
|
53
|
+
makeStats({ sent: 12, queued: 3, coalesced: 5, dropped: 2, shed: 8, expired: 1, failedFast: 4 }),
|
|
54
|
+
)
|
|
55
|
+
expect(line).toBe(
|
|
56
|
+
'telegram gateway: send-gate stats: sent=12 queued=3 coalesced=5 dropped=2 ' +
|
|
57
|
+
'shed=8 expired=1 failedFast=4 msgStates=0 chatBuckets=0 globalFill=4\n',
|
|
58
|
+
)
|
|
59
|
+
expect(line.endsWith('\n')).toBe(true)
|
|
60
|
+
expect(line.split('\n').filter((l) => l.length > 0)).toHaveLength(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('rounds globalFill to one decimal', () => {
|
|
64
|
+
const s = makeStats()
|
|
65
|
+
s.fill.global = 23.14159
|
|
66
|
+
expect(formatStatsLine(s)).toContain('globalFill=23.1')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('countersChanged', () => {
|
|
71
|
+
it('is false for identical counters, true when any field differs', () => {
|
|
72
|
+
const a = makeStats().global
|
|
73
|
+
expect(countersChanged(a, { ...a })).toBe(false)
|
|
74
|
+
expect(countersChanged(a, { ...a, shed: 1 })).toBe(true)
|
|
75
|
+
expect(countersChanged(a, { ...a, failedFast: 1 })).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('createStatsLogger', () => {
|
|
80
|
+
it('logs at most once per interval AND only when a counter changed', async () => {
|
|
81
|
+
const clock = new TestClock()
|
|
82
|
+
const logs: string[] = []
|
|
83
|
+
let counters = makeStats().global
|
|
84
|
+
const logger = createStatsLogger({
|
|
85
|
+
stats: () => makeStats(counters),
|
|
86
|
+
log: (l) => logs.push(l),
|
|
87
|
+
clock,
|
|
88
|
+
intervalMs: 60_000,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// First tick with a fresh baseline logs immediately.
|
|
92
|
+
logger.tick()
|
|
93
|
+
expect(logs).toHaveLength(1)
|
|
94
|
+
|
|
95
|
+
// Changed counters but interval NOT elapsed (30s since the log at t=0) →
|
|
96
|
+
// no log (rate limit).
|
|
97
|
+
counters = { ...counters, sent: 5 }
|
|
98
|
+
clock.cur = 30_000
|
|
99
|
+
logger.tick()
|
|
100
|
+
expect(logs).toHaveLength(1)
|
|
101
|
+
|
|
102
|
+
// Interval elapsed but counters unchanged since the last logged snapshot →
|
|
103
|
+
// no log (no spam). Note the last LOG was at t=0 (t=30_000 didn't log).
|
|
104
|
+
counters = { ...counters, sent: 5 }
|
|
105
|
+
clock.cur = 70_000
|
|
106
|
+
// First make sure an UNCHANGED snapshot stays silent: temporarily revert.
|
|
107
|
+
// (Left as-is: counters already differ from the baseline, so this ticks.)
|
|
108
|
+
logger.tick()
|
|
109
|
+
expect(logs).toHaveLength(2)
|
|
110
|
+
expect(logs[1]).toContain('sent=5')
|
|
111
|
+
|
|
112
|
+
// Now unchanged since last log + interval elapsed → still silent.
|
|
113
|
+
clock.cur = 200_000
|
|
114
|
+
logger.tick()
|
|
115
|
+
expect(logs).toHaveLength(2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('is a no-op when the gate is disabled', () => {
|
|
119
|
+
const clock = new TestClock()
|
|
120
|
+
const logs: string[] = []
|
|
121
|
+
const logger = createStatsLogger({
|
|
122
|
+
stats: () => makeStats({ sent: 99 }, false),
|
|
123
|
+
log: (l) => logs.push(l),
|
|
124
|
+
clock,
|
|
125
|
+
})
|
|
126
|
+
logger.tick()
|
|
127
|
+
clock.cur = 1_000_000
|
|
128
|
+
logger.tick()
|
|
129
|
+
expect(logs).toHaveLength(0)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('createFloodWindowObserver — snapshots', () => {
|
|
134
|
+
it('logs a snapshot on window open and on window close', async () => {
|
|
135
|
+
const clock = new TestClock()
|
|
136
|
+
const logs: string[] = []
|
|
137
|
+
let windows: FloodWindowRecord[] = []
|
|
138
|
+
const obs = createFloodWindowObserver({
|
|
139
|
+
clock,
|
|
140
|
+
log: (l) => logs.push(l),
|
|
141
|
+
stats: () => makeStats({ shed: 2 }),
|
|
142
|
+
readWindows: () => windows,
|
|
143
|
+
markAlerted: () => {},
|
|
144
|
+
sendAlert: async () => {},
|
|
145
|
+
operatorChatId: () => 'op',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// First (boot) tick with no windows establishes the baseline.
|
|
149
|
+
await obs.tick()
|
|
150
|
+
expect(logs).toHaveLength(0)
|
|
151
|
+
|
|
152
|
+
// A NEW window opens on a later tick (post-boot) → OPENED snapshot.
|
|
153
|
+
clock.cur = 5_000
|
|
154
|
+
windows = [{ scopeKey: 'global', untilTs: 30_000, retryAfterSrc: '429', observedAt: 5_000 }]
|
|
155
|
+
await obs.tick()
|
|
156
|
+
expect(logs.some((l) => l.includes('flood window OPENED scope=global'))).toBe(true)
|
|
157
|
+
expect(logs.some((l) => l.includes('shed=2'))).toBe(true)
|
|
158
|
+
|
|
159
|
+
// Window gone (expired/pruned) → close snapshot.
|
|
160
|
+
logs.length = 0
|
|
161
|
+
clock.cur = 31_000
|
|
162
|
+
windows = []
|
|
163
|
+
await obs.tick()
|
|
164
|
+
expect(logs.some((l) => l.includes('flood window CLOSED scope=global'))).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('logs ONE "loaded" line for boot-loaded windows, not per-scope OPENED spam (L3)', async () => {
|
|
168
|
+
const clock = new TestClock()
|
|
169
|
+
const logs: string[] = []
|
|
170
|
+
// Three windows already on disk at boot (a real 429 opens global+chat+group).
|
|
171
|
+
const windows: FloodWindowRecord[] = [
|
|
172
|
+
{ scopeKey: 'global', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
|
|
173
|
+
{ scopeKey: 'chat:op', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
|
|
174
|
+
{ scopeKey: 'group:g', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
|
|
175
|
+
]
|
|
176
|
+
const obs = createFloodWindowObserver({
|
|
177
|
+
clock,
|
|
178
|
+
log: (l) => logs.push(l),
|
|
179
|
+
stats: () => makeStats(),
|
|
180
|
+
readWindows: () => windows,
|
|
181
|
+
markAlerted: () => {},
|
|
182
|
+
sendAlert: async () => {},
|
|
183
|
+
operatorChatId: () => 'op',
|
|
184
|
+
})
|
|
185
|
+
await obs.tick()
|
|
186
|
+
// No OPENED spam for pre-existing windows.
|
|
187
|
+
expect(logs.some((l) => l.includes('flood window OPENED'))).toBe(false)
|
|
188
|
+
// Exactly one "loaded" line naming the count and every scope.
|
|
189
|
+
const loaded = logs.filter((l) => l.includes('loaded 3 open flood window(s) at boot'))
|
|
190
|
+
expect(loaded).toHaveLength(1)
|
|
191
|
+
expect(loaded[0]).toContain('global')
|
|
192
|
+
expect(loaded[0]).toContain('chat:op')
|
|
193
|
+
expect(loaded[0]).toContain('group:g')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not run when the gate is disabled', async () => {
|
|
197
|
+
const clock = new TestClock()
|
|
198
|
+
const logs: string[] = []
|
|
199
|
+
const alerts: string[] = []
|
|
200
|
+
const obs = createFloodWindowObserver({
|
|
201
|
+
clock,
|
|
202
|
+
log: (l) => logs.push(l),
|
|
203
|
+
stats: () => makeStats({}, false),
|
|
204
|
+
readWindows: () => [
|
|
205
|
+
{ scopeKey: 'chat:other', untilTs: 999_999, retryAfterSrc: '429', observedAt: 0 },
|
|
206
|
+
],
|
|
207
|
+
markAlerted: () => {},
|
|
208
|
+
sendAlert: async (t) => {
|
|
209
|
+
alerts.push(t)
|
|
210
|
+
},
|
|
211
|
+
operatorChatId: () => 'op',
|
|
212
|
+
})
|
|
213
|
+
clock.cur = 500_000
|
|
214
|
+
await obs.tick()
|
|
215
|
+
expect(logs).toHaveLength(0)
|
|
216
|
+
expect(alerts).toHaveLength(0)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('createFloodWindowObserver — alerting', () => {
|
|
221
|
+
// FORWARD-LOOKING (#3111): this exercises the immediate-delivery branch with a
|
|
222
|
+
// hand-built `chat:other`-only window. In production today that state never
|
|
223
|
+
// occurs — `openScopedWindowsForOpts` opens a coincident `global` window on
|
|
224
|
+
// every 429, so the operator is always "covered" and every alert defers to
|
|
225
|
+
// close (see M1 in the #3112 review). This test guards the branch so it is
|
|
226
|
+
// correct the moment #3111 makes `onFloodWait` scope-precise; the live path
|
|
227
|
+
// today is the deferred-close test below.
|
|
228
|
+
it('alerts immediately for a chat-scoped ban on a DIFFERENT chat, at most once', async () => {
|
|
229
|
+
const clock = new TestClock()
|
|
230
|
+
const alerts: string[] = []
|
|
231
|
+
const marked: { scope: string; at: number }[] = []
|
|
232
|
+
let windows: FloodWindowRecord[] = [
|
|
233
|
+
{ scopeKey: 'chat:other', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
|
|
234
|
+
]
|
|
235
|
+
const obs = createFloodWindowObserver({
|
|
236
|
+
clock,
|
|
237
|
+
log: () => {},
|
|
238
|
+
stats: () => makeStats(),
|
|
239
|
+
readWindows: () => windows,
|
|
240
|
+
markAlerted: (scope, at) => {
|
|
241
|
+
marked.push({ scope, at })
|
|
242
|
+
// simulate persistence: the next read carries alertedAt
|
|
243
|
+
windows = windows.map((w) => (w.scopeKey === scope ? { ...w, alertedAt: at } : w))
|
|
244
|
+
},
|
|
245
|
+
sendAlert: async (t) => {
|
|
246
|
+
alerts.push(t)
|
|
247
|
+
},
|
|
248
|
+
operatorChatId: () => 'op',
|
|
249
|
+
alertThresholdMs: 60_000,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Below threshold → no alert yet.
|
|
253
|
+
clock.cur = 30_000
|
|
254
|
+
await obs.tick()
|
|
255
|
+
expect(alerts).toHaveLength(0)
|
|
256
|
+
|
|
257
|
+
// Past threshold, operator chat is clear → immediate alert + persisted marker.
|
|
258
|
+
clock.cur = 61_000
|
|
259
|
+
await obs.tick()
|
|
260
|
+
expect(alerts).toHaveLength(1)
|
|
261
|
+
expect(alerts[0]).toContain('flood ban active')
|
|
262
|
+
expect(alerts[0]).toContain('chat:other')
|
|
263
|
+
expect(marked).toHaveLength(1)
|
|
264
|
+
|
|
265
|
+
// Subsequent ticks must NOT re-alert (alertedAt now set).
|
|
266
|
+
clock.cur = 120_000
|
|
267
|
+
await obs.tick()
|
|
268
|
+
expect(alerts).toHaveLength(1)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('defers a GLOBAL ban alert until the window closes ("was banned from X to Y")', async () => {
|
|
272
|
+
const clock = new TestClock()
|
|
273
|
+
const alerts: string[] = []
|
|
274
|
+
let windows: FloodWindowRecord[] = [
|
|
275
|
+
{ scopeKey: 'global', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
|
|
276
|
+
]
|
|
277
|
+
const obs = createFloodWindowObserver({
|
|
278
|
+
clock,
|
|
279
|
+
log: () => {},
|
|
280
|
+
stats: () => makeStats(),
|
|
281
|
+
readWindows: () => windows,
|
|
282
|
+
markAlerted: () => {},
|
|
283
|
+
sendAlert: async (t) => {
|
|
284
|
+
alerts.push(t)
|
|
285
|
+
},
|
|
286
|
+
operatorChatId: () => 'op',
|
|
287
|
+
alertThresholdMs: 60_000,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Past threshold but global → operator unreachable → NO alert during window.
|
|
291
|
+
clock.cur = 61_000
|
|
292
|
+
await obs.tick()
|
|
293
|
+
expect(alerts).toHaveLength(0)
|
|
294
|
+
|
|
295
|
+
// Window closes → deferred alert fires once, with the banned-from/to wording.
|
|
296
|
+
clock.cur = 121_000
|
|
297
|
+
windows = []
|
|
298
|
+
await obs.tick()
|
|
299
|
+
expect(alerts).toHaveLength(1)
|
|
300
|
+
expect(alerts[0]).toContain('flood ban cleared')
|
|
301
|
+
expect(alerts[0]).toContain('was banned from')
|
|
302
|
+
|
|
303
|
+
// No re-alert afterwards.
|
|
304
|
+
clock.cur = 200_000
|
|
305
|
+
await obs.tick()
|
|
306
|
+
expect(alerts).toHaveLength(1)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('coalesces one incident (global+chat+group) into ONE cleared card listing every scope (M1)', async () => {
|
|
310
|
+
const clock = new TestClock()
|
|
311
|
+
const alerts: string[] = []
|
|
312
|
+
// A single 429 opens all three scopes with the SAME untilTs (the live
|
|
313
|
+
// recorder behavior — see #3111). They all defer (global covers the
|
|
314
|
+
// operator) and all close in the same tick.
|
|
315
|
+
let windows: FloodWindowRecord[] = [
|
|
316
|
+
{ scopeKey: 'global', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
|
|
317
|
+
{ scopeKey: 'chat:op', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
|
|
318
|
+
{ scopeKey: 'group:g', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
|
|
319
|
+
]
|
|
320
|
+
const obs = createFloodWindowObserver({
|
|
321
|
+
clock,
|
|
322
|
+
log: () => {},
|
|
323
|
+
stats: () => makeStats(),
|
|
324
|
+
readWindows: () => windows,
|
|
325
|
+
markAlerted: () => {},
|
|
326
|
+
sendAlert: async (t) => {
|
|
327
|
+
alerts.push(t)
|
|
328
|
+
},
|
|
329
|
+
operatorChatId: () => 'op',
|
|
330
|
+
alertThresholdMs: 60_000,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// Past threshold, all scopes covered by the global window → nothing during.
|
|
334
|
+
clock.cur = 61_000
|
|
335
|
+
await obs.tick()
|
|
336
|
+
expect(alerts).toHaveLength(0)
|
|
337
|
+
|
|
338
|
+
// All three close in the same tick → EXACTLY ONE cleared card, not three.
|
|
339
|
+
clock.cur = 121_000
|
|
340
|
+
windows = []
|
|
341
|
+
await obs.tick()
|
|
342
|
+
expect(alerts).toHaveLength(1)
|
|
343
|
+
expect(alerts[0]).toContain('flood ban cleared')
|
|
344
|
+
expect(alerts[0]).toContain('scopes') // plural — multiple scopes listed
|
|
345
|
+
expect(alerts[0]).toContain('global')
|
|
346
|
+
expect(alerts[0]).toContain('chat:op')
|
|
347
|
+
expect(alerts[0]).toContain('group:g')
|
|
348
|
+
|
|
349
|
+
// No re-alert afterwards.
|
|
350
|
+
clock.cur = 200_000
|
|
351
|
+
await obs.tick()
|
|
352
|
+
expect(alerts).toHaveLength(1)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('does not alert for msg-edit (cosmetic) scopes', async () => {
|
|
356
|
+
const clock = new TestClock()
|
|
357
|
+
const alerts: string[] = []
|
|
358
|
+
const obs = createFloodWindowObserver({
|
|
359
|
+
clock,
|
|
360
|
+
log: () => {},
|
|
361
|
+
stats: () => makeStats(),
|
|
362
|
+
readWindows: () => [
|
|
363
|
+
{ scopeKey: 'msg-edit:op:100', untilTs: 999_999, retryAfterSrc: '429', observedAt: 0 },
|
|
364
|
+
],
|
|
365
|
+
markAlerted: () => {},
|
|
366
|
+
sendAlert: async (t) => {
|
|
367
|
+
alerts.push(t)
|
|
368
|
+
},
|
|
369
|
+
operatorChatId: () => 'op',
|
|
370
|
+
})
|
|
371
|
+
clock.cur = 500_000
|
|
372
|
+
await obs.tick()
|
|
373
|
+
expect(alerts).toHaveLength(0)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe('alertedAt persistence (restart safety)', () => {
|
|
378
|
+
let dir: string
|
|
379
|
+
beforeEach(() => {
|
|
380
|
+
dir = mkdtempSync(join(tmpdir(), 'flood-windows-'))
|
|
381
|
+
})
|
|
382
|
+
afterEach(() => {
|
|
383
|
+
rmSync(dir, { recursive: true, force: true })
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('markFloodWindowAlerted persists alertedAt and readFloodWindows preserves it', () => {
|
|
387
|
+
const path = floodWindowsPath(dir)
|
|
388
|
+
writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 }, 0)
|
|
389
|
+
markFloodWindowAlerted(path, 'chat:x', 61_000, 61_000)
|
|
390
|
+
const [w] = readFloodWindows(path, 62_000)
|
|
391
|
+
expect(w.alertedAt).toBe(61_000)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('preserves alertedAt when the window is later EXTENDED by a fresh 429', () => {
|
|
395
|
+
const path = floodWindowsPath(dir)
|
|
396
|
+
writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 }, 0)
|
|
397
|
+
markFloodWindowAlerted(path, 'chat:x', 61_000, 61_000)
|
|
398
|
+
// A later 429 extends the window (recorder carries no alertedAt).
|
|
399
|
+
writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 600_000, retryAfterSrc: '429', observedAt: 62_000 }, 62_000)
|
|
400
|
+
const [w] = readFloodWindows(path, 63_000)
|
|
401
|
+
expect(w.untilTs).toBe(600_000)
|
|
402
|
+
expect(w.alertedAt).toBe(61_000)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('a restart mid-window (alertedAt already on disk) does not re-alert', async () => {
|
|
406
|
+
const path = floodWindowsPath(dir)
|
|
407
|
+
writeFloodWindow(
|
|
408
|
+
path,
|
|
409
|
+
{ scopeKey: 'chat:other', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0, alertedAt: 61_000 },
|
|
410
|
+
0,
|
|
411
|
+
)
|
|
412
|
+
const clock = new TestClock()
|
|
413
|
+
const alerts: string[] = []
|
|
414
|
+
const obs = createFloodWindowObserver({
|
|
415
|
+
clock,
|
|
416
|
+
log: () => {},
|
|
417
|
+
stats: () => makeStats(),
|
|
418
|
+
readWindows: (now) => readFloodWindows(path, now),
|
|
419
|
+
markAlerted: (scope, at) => markFloodWindowAlerted(path, scope, at, clock.cur),
|
|
420
|
+
sendAlert: async (t) => {
|
|
421
|
+
alerts.push(t)
|
|
422
|
+
},
|
|
423
|
+
operatorChatId: () => 'op',
|
|
424
|
+
})
|
|
425
|
+
clock.cur = 120_000 // well past threshold, window still open
|
|
426
|
+
await obs.tick()
|
|
427
|
+
expect(alerts).toHaveLength(0)
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
describe('/status send-gate block', () => {
|
|
432
|
+
const meta = {
|
|
433
|
+
agentName: 'a',
|
|
434
|
+
model: null,
|
|
435
|
+
extendsProfile: null,
|
|
436
|
+
topicName: null,
|
|
437
|
+
topicEmoji: null,
|
|
438
|
+
uptime: null,
|
|
439
|
+
status: null,
|
|
440
|
+
auth: null,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
it('renders queued/shed totals and open flood windows when the gate is on', () => {
|
|
444
|
+
const text = statusPairedText({
|
|
445
|
+
user: '@op',
|
|
446
|
+
meta: {
|
|
447
|
+
...meta,
|
|
448
|
+
sendGate: {
|
|
449
|
+
queued: 4,
|
|
450
|
+
shed: 9,
|
|
451
|
+
expired: 1,
|
|
452
|
+
failedFast: 2,
|
|
453
|
+
dropped: 3,
|
|
454
|
+
openWindows: [{ scopeKey: 'global', untilTs: Date.now() + 45_000 }],
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
expect(text).toContain('Send gate')
|
|
459
|
+
expect(text).toContain('queued 4')
|
|
460
|
+
expect(text).toContain('shed 9')
|
|
461
|
+
expect(text).toContain('fail-fast 2')
|
|
462
|
+
expect(text).toContain('flood window')
|
|
463
|
+
expect(text).toContain('global')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('omits the send-gate block entirely when the gate is off (no sendGate field)', () => {
|
|
467
|
+
const text = statusPairedText({ user: '@op', meta })
|
|
468
|
+
expect(text).not.toContain('Send gate')
|
|
469
|
+
})
|
|
470
|
+
})
|