@animalabs/agent-framework 0.4.1 → 0.5.1
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/src/agent.d.ts +7 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +9 -3
- package/dist/src/agent.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +27 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +286 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +65 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/test/event-gate-rate-limit.test.d.ts +2 -0
- package/dist/test/event-gate-rate-limit.test.d.ts.map +1 -0
- package/dist/test/event-gate-rate-limit.test.js +627 -0
- package/dist/test/event-gate-rate-limit.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/agent.ts +10 -4
- package/src/gate/event-gate.ts +309 -22
- package/src/gate/types.ts +66 -1
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { EventGate } from '../src/gate/event-gate.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test scaffolding — fake-clock gate, deterministic across runs.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-ratelimit');
|
|
10
|
+
function tmpPath(name) {
|
|
11
|
+
return join(TMP_DIR, name);
|
|
12
|
+
}
|
|
13
|
+
function makeFakeClockGate(initialConfig, startTime = 1_000_000) {
|
|
14
|
+
let currentTime = startTime;
|
|
15
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
16
|
+
const configPath = tmpPath(`gate-${Math.random().toString(36).slice(2)}.json`);
|
|
17
|
+
const traces = [];
|
|
18
|
+
const gate = new EventGate({
|
|
19
|
+
configPath,
|
|
20
|
+
initialConfig,
|
|
21
|
+
emitTrace: (e) => traces.push(e),
|
|
22
|
+
addMessage: () => '',
|
|
23
|
+
requestInference: () => { },
|
|
24
|
+
getAgentNames: () => ['agent'],
|
|
25
|
+
now: () => currentTime,
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
gate,
|
|
29
|
+
configPath,
|
|
30
|
+
traces,
|
|
31
|
+
advance(ms) { currentTime += ms; },
|
|
32
|
+
setNow(ms) { currentTime = ms; },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Force the next evaluate() to do a fresh file-mtime check. */
|
|
36
|
+
async function rewriteConfig(h, next) {
|
|
37
|
+
// Bypass the 1s reload-check throttle (existing test pattern).
|
|
38
|
+
h.gate.lastReloadCheck = 0;
|
|
39
|
+
// Wait long enough for the on-disk mtime to actually advance — fs
|
|
40
|
+
// resolution is millisecond-level on most platforms.
|
|
41
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
42
|
+
writeFileSync(h.configPath, JSON.stringify(next, null, 2));
|
|
43
|
+
}
|
|
44
|
+
function event(overrides) {
|
|
45
|
+
return {
|
|
46
|
+
content: 'test',
|
|
47
|
+
eventType: 'mcpl:channel-incoming',
|
|
48
|
+
serverId: 'telegram',
|
|
49
|
+
channelId: 'telegram:default:supergroup:1',
|
|
50
|
+
metadata: {},
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
if (existsSync(TMP_DIR)) {
|
|
59
|
+
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Validation
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
describe('validation: new behaviors', () => {
|
|
66
|
+
// Validation errors during initialConfig seeding land in getStatus().errors
|
|
67
|
+
// rather than throwing — this matches the existing pattern where a bad
|
|
68
|
+
// gate.json edit doesn't crash the running gate, just reports the issue.
|
|
69
|
+
it('reports rate_limit with non-positive tokens', () => {
|
|
70
|
+
const { gate } = makeFakeClockGate({
|
|
71
|
+
policies: [{
|
|
72
|
+
name: 'p',
|
|
73
|
+
match: {},
|
|
74
|
+
behavior: { rate_limit: { tokens: 0, refillIntervalMs: 1000 } },
|
|
75
|
+
}],
|
|
76
|
+
});
|
|
77
|
+
const errors = gate.getStatus().errors;
|
|
78
|
+
assert.ok(errors.some(e => /tokens must be a positive number/.test(e)), `expected validation error, got: ${JSON.stringify(errors)}`);
|
|
79
|
+
});
|
|
80
|
+
it('reports rate_limit with non-positive refillIntervalMs', () => {
|
|
81
|
+
const { gate } = makeFakeClockGate({
|
|
82
|
+
policies: [{
|
|
83
|
+
name: 'p',
|
|
84
|
+
match: {},
|
|
85
|
+
behavior: { rate_limit: { tokens: 5, refillIntervalMs: -1 } },
|
|
86
|
+
}],
|
|
87
|
+
});
|
|
88
|
+
assert.ok(gate.getStatus().errors.some(e => /refillIntervalMs must be a positive number/.test(e)));
|
|
89
|
+
});
|
|
90
|
+
it('reports passive_sample with non-integer every', () => {
|
|
91
|
+
const { gate } = makeFakeClockGate({
|
|
92
|
+
policies: [{
|
|
93
|
+
name: 'p',
|
|
94
|
+
match: {},
|
|
95
|
+
behavior: { passive_sample: { every: 1.5 } },
|
|
96
|
+
}],
|
|
97
|
+
});
|
|
98
|
+
assert.ok(gate.getStatus().errors.some(e => /every must be a positive integer/.test(e)));
|
|
99
|
+
});
|
|
100
|
+
it('accepts well-formed rate_limit and passive_sample without keyBy', () => {
|
|
101
|
+
const { gate } = makeFakeClockGate({
|
|
102
|
+
policies: [
|
|
103
|
+
{ name: 'rl', match: {}, behavior: { rate_limit: { tokens: 1, refillIntervalMs: 1000 } } },
|
|
104
|
+
],
|
|
105
|
+
default: 'skip',
|
|
106
|
+
});
|
|
107
|
+
// With tokens=1, the first call passes, the second is denied.
|
|
108
|
+
assert.strictEqual(gate.evaluate(event()).trigger, true);
|
|
109
|
+
assert.strictEqual(gate.evaluate(event()).trigger, false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// metadataTrue match
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
describe('match: metadataTrue', () => {
|
|
116
|
+
it('matches when ANY listed metadata field is truthy', () => {
|
|
117
|
+
const { gate } = makeFakeClockGate({
|
|
118
|
+
policies: [{
|
|
119
|
+
name: 'direct',
|
|
120
|
+
match: { metadataTrue: ['isMention', 'isPrivate'] },
|
|
121
|
+
behavior: 'always',
|
|
122
|
+
}],
|
|
123
|
+
default: 'skip',
|
|
124
|
+
});
|
|
125
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true } })).trigger, true);
|
|
126
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isPrivate: true } })).trigger, true);
|
|
127
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: false, isPrivate: false } })).trigger, false);
|
|
128
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
129
|
+
});
|
|
130
|
+
it('treats empty arrays/strings/objects as falsy (not raw JS Boolean)', () => {
|
|
131
|
+
const { gate } = makeFakeClockGate({
|
|
132
|
+
policies: [{
|
|
133
|
+
name: 'direct',
|
|
134
|
+
match: { metadataTrue: ['mentionIds'] },
|
|
135
|
+
behavior: 'always',
|
|
136
|
+
}],
|
|
137
|
+
default: 'skip',
|
|
138
|
+
});
|
|
139
|
+
// Empty array — would be truthy under raw Boolean(), but we want falsy.
|
|
140
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { mentionIds: [] } })).trigger, false);
|
|
141
|
+
// Non-empty array — truthy.
|
|
142
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { mentionIds: ['user-99'] } })).trigger, true);
|
|
143
|
+
// Empty string — falsy.
|
|
144
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { mentionIds: '' } })).trigger, false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// rate_limit
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
describe('rate_limit', () => {
|
|
151
|
+
function rlConfig() {
|
|
152
|
+
return {
|
|
153
|
+
policies: [{
|
|
154
|
+
name: 'rl',
|
|
155
|
+
match: { metadataTrue: ['isMention'] },
|
|
156
|
+
behavior: { rate_limit: { tokens: 12, refillIntervalMs: 1000, keyBy: 'senderId' } },
|
|
157
|
+
}],
|
|
158
|
+
default: 'skip',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
it('drains the bucket and denies the 13th invocation in a row', () => {
|
|
162
|
+
const { gate } = makeFakeClockGate(rlConfig());
|
|
163
|
+
for (let i = 0; i < 12; i++) {
|
|
164
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'user-1' } })).trigger, true, `invocation ${i + 1} should pass`);
|
|
165
|
+
}
|
|
166
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'user-1' } })).trigger, false, '13th invocation must be denied');
|
|
167
|
+
});
|
|
168
|
+
it('regenerates one token per refillIntervalMs', () => {
|
|
169
|
+
const { gate, advance } = makeFakeClockGate(rlConfig());
|
|
170
|
+
for (let i = 0; i < 12; i++) {
|
|
171
|
+
gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } }));
|
|
172
|
+
}
|
|
173
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, false);
|
|
174
|
+
advance(1000);
|
|
175
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, true, 'one token should regenerate after refillIntervalMs');
|
|
176
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, false, 'no second token yet — should still be denied');
|
|
177
|
+
});
|
|
178
|
+
it('caps refill at the configured tokens (no leaking)', () => {
|
|
179
|
+
const { gate, advance } = makeFakeClockGate(rlConfig());
|
|
180
|
+
// drain
|
|
181
|
+
for (let i = 0; i < 12; i++) {
|
|
182
|
+
gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } }));
|
|
183
|
+
}
|
|
184
|
+
advance(100_000); // way past full refill
|
|
185
|
+
// Should now have full 12 tokens — not 100.
|
|
186
|
+
for (let i = 0; i < 12; i++) {
|
|
187
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, true, `post-refill invocation ${i + 1}`);
|
|
188
|
+
}
|
|
189
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, false, '13th post-refill invocation should be denied');
|
|
190
|
+
});
|
|
191
|
+
it('keyBy partitions buckets — different users have independent quotas', () => {
|
|
192
|
+
const { gate } = makeFakeClockGate(rlConfig());
|
|
193
|
+
for (let i = 0; i < 12; i++) {
|
|
194
|
+
gate.evaluate(event({ metadata: { isMention: true, senderId: 'user-A' } }));
|
|
195
|
+
}
|
|
196
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'user-A' } })).trigger, false);
|
|
197
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'user-B' } })).trigger, true, 'user-B bucket must be independent');
|
|
198
|
+
});
|
|
199
|
+
it('missing keyBy field falls back to a single shared bucket', () => {
|
|
200
|
+
const { gate } = makeFakeClockGate({
|
|
201
|
+
policies: [{
|
|
202
|
+
name: 'rl',
|
|
203
|
+
match: {},
|
|
204
|
+
behavior: { rate_limit: { tokens: 2, refillIntervalMs: 1000, keyBy: 'senderId' } },
|
|
205
|
+
}],
|
|
206
|
+
default: 'skip',
|
|
207
|
+
});
|
|
208
|
+
// No senderId — these all share the single fallback bucket.
|
|
209
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, true);
|
|
210
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, true);
|
|
211
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false, 'shared bucket should drain like any other');
|
|
212
|
+
});
|
|
213
|
+
it('terminates with trigger:false on deny — does not fall through to default:always', () => {
|
|
214
|
+
const { gate } = makeFakeClockGate({
|
|
215
|
+
policies: [{
|
|
216
|
+
name: 'rl',
|
|
217
|
+
match: { metadataTrue: ['isMention'] },
|
|
218
|
+
behavior: { rate_limit: { tokens: 1, refillIntervalMs: 1000 } },
|
|
219
|
+
}],
|
|
220
|
+
default: 'always',
|
|
221
|
+
});
|
|
222
|
+
// First passes.
|
|
223
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true } })).trigger, true);
|
|
224
|
+
// Second is denied — must NOT fall through to default:always.
|
|
225
|
+
const denied = gate.evaluate(event({ metadata: { isMention: true } }));
|
|
226
|
+
assert.strictEqual(denied.trigger, false);
|
|
227
|
+
assert.strictEqual(denied.policyName, 'rl');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// passive_sample
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
describe('passive_sample', () => {
|
|
234
|
+
it('fires every Nth match, then resets', () => {
|
|
235
|
+
const { gate } = makeFakeClockGate({
|
|
236
|
+
policies: [{
|
|
237
|
+
name: 'ps',
|
|
238
|
+
match: {},
|
|
239
|
+
behavior: { passive_sample: { every: 3 } },
|
|
240
|
+
}],
|
|
241
|
+
default: 'skip',
|
|
242
|
+
});
|
|
243
|
+
assert.strictEqual(gate.evaluate(event()).trigger, false);
|
|
244
|
+
assert.strictEqual(gate.evaluate(event()).trigger, false);
|
|
245
|
+
assert.strictEqual(gate.evaluate(event()).trigger, true); // 3rd → fires
|
|
246
|
+
assert.strictEqual(gate.evaluate(event()).trigger, false); // counter reset
|
|
247
|
+
assert.strictEqual(gate.evaluate(event()).trigger, false);
|
|
248
|
+
assert.strictEqual(gate.evaluate(event()).trigger, true); // 6th
|
|
249
|
+
});
|
|
250
|
+
it('keyBy partitions counters — busy channel does not affect quiet one', () => {
|
|
251
|
+
const { gate } = makeFakeClockGate({
|
|
252
|
+
policies: [{
|
|
253
|
+
name: 'ps',
|
|
254
|
+
match: {},
|
|
255
|
+
behavior: { passive_sample: { every: 3, keyBy: 'channelId' } },
|
|
256
|
+
}],
|
|
257
|
+
default: 'skip',
|
|
258
|
+
});
|
|
259
|
+
// 3 messages in A → 3rd fires
|
|
260
|
+
for (let i = 0; i < 2; i++) {
|
|
261
|
+
assert.strictEqual(gate.evaluate(event({ channelId: 'A', metadata: { channelId: 'A' } })).trigger, false);
|
|
262
|
+
}
|
|
263
|
+
assert.strictEqual(gate.evaluate(event({ channelId: 'A', metadata: { channelId: 'A' } })).trigger, true);
|
|
264
|
+
// First message in B → must not fire (counter independent)
|
|
265
|
+
assert.strictEqual(gate.evaluate(event({ channelId: 'B', metadata: { channelId: 'B' } })).trigger, false);
|
|
266
|
+
});
|
|
267
|
+
it('terminates with trigger:false on non-firing counter — no fallthrough to default', () => {
|
|
268
|
+
const { gate } = makeFakeClockGate({
|
|
269
|
+
policies: [{
|
|
270
|
+
name: 'ps',
|
|
271
|
+
match: {},
|
|
272
|
+
behavior: { passive_sample: { every: 100 } },
|
|
273
|
+
}],
|
|
274
|
+
default: 'always',
|
|
275
|
+
});
|
|
276
|
+
const decision = gate.evaluate(event());
|
|
277
|
+
assert.strictEqual(decision.trigger, false);
|
|
278
|
+
assert.strictEqual(decision.policyName, 'ps');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// resets cross-policy
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
describe('resets', () => {
|
|
285
|
+
it('firing a policy clears the rate_limit and passive_sample state of named targets', () => {
|
|
286
|
+
const config = {
|
|
287
|
+
policies: [
|
|
288
|
+
{
|
|
289
|
+
name: 'direct',
|
|
290
|
+
match: { metadataTrue: ['isMention'] },
|
|
291
|
+
behavior: { rate_limit: { tokens: 12, refillIntervalMs: 1000, keyBy: 'senderId' } },
|
|
292
|
+
resets: ['passive'],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: 'passive',
|
|
296
|
+
match: {},
|
|
297
|
+
behavior: { passive_sample: { every: 5 } },
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
default: 'skip',
|
|
301
|
+
};
|
|
302
|
+
const { gate } = makeFakeClockGate(config);
|
|
303
|
+
// 4 quiet messages — passive counter at 4
|
|
304
|
+
for (let i = 0; i < 4; i++) {
|
|
305
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
306
|
+
}
|
|
307
|
+
// Direct invocation fires AND resets the passive counter.
|
|
308
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, true);
|
|
309
|
+
// Now we need ANOTHER 5 quiet messages, not 1 more.
|
|
310
|
+
for (let i = 0; i < 4; i++) {
|
|
311
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
312
|
+
}
|
|
313
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, true);
|
|
314
|
+
});
|
|
315
|
+
it('denied rate_limit does NOT trigger resets', () => {
|
|
316
|
+
const config = {
|
|
317
|
+
policies: [
|
|
318
|
+
{
|
|
319
|
+
name: 'direct',
|
|
320
|
+
match: { metadataTrue: ['isMention'] },
|
|
321
|
+
behavior: { rate_limit: { tokens: 1, refillIntervalMs: 10_000 } },
|
|
322
|
+
resets: ['passive'],
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'passive',
|
|
326
|
+
match: {},
|
|
327
|
+
behavior: { passive_sample: { every: 3 } },
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
default: 'skip',
|
|
331
|
+
};
|
|
332
|
+
const { gate } = makeFakeClockGate(config);
|
|
333
|
+
// Burn the only token via direct invocation — fires, counter resets to 0
|
|
334
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, true);
|
|
335
|
+
// 2 quiet messages — counter at 2
|
|
336
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
337
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
338
|
+
// Denied direct invocation — must NOT reset counter
|
|
339
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { isMention: true, senderId: 'u' } })).trigger, false);
|
|
340
|
+
// Next quiet message should still fire (counter was preserved at 2 → reaches 3)
|
|
341
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, true);
|
|
342
|
+
});
|
|
343
|
+
it('always-behavior with resets clears target state too', () => {
|
|
344
|
+
const config = {
|
|
345
|
+
policies: [
|
|
346
|
+
{
|
|
347
|
+
name: 'priority',
|
|
348
|
+
match: { metadataTrue: ['urgent'] },
|
|
349
|
+
behavior: 'always',
|
|
350
|
+
resets: ['passive'],
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'passive',
|
|
354
|
+
match: {},
|
|
355
|
+
behavior: { passive_sample: { every: 5 } },
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
default: 'skip',
|
|
359
|
+
};
|
|
360
|
+
const { gate } = makeFakeClockGate(config);
|
|
361
|
+
// pile up the passive counter
|
|
362
|
+
for (let i = 0; i < 4; i++)
|
|
363
|
+
gate.evaluate(event({ metadata: {} }));
|
|
364
|
+
// urgent fires, resets passive
|
|
365
|
+
assert.strictEqual(gate.evaluate(event({ metadata: { urgent: true } })).trigger, true);
|
|
366
|
+
// need full 5 quiet messages now
|
|
367
|
+
for (let i = 0; i < 4; i++) {
|
|
368
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, false);
|
|
369
|
+
}
|
|
370
|
+
assert.strictEqual(gate.evaluate(event({ metadata: {} })).trigger, true);
|
|
371
|
+
});
|
|
372
|
+
it('unknown reset target is silently ignored', () => {
|
|
373
|
+
const { gate } = makeFakeClockGate({
|
|
374
|
+
policies: [{
|
|
375
|
+
name: 'p',
|
|
376
|
+
match: {},
|
|
377
|
+
behavior: 'always',
|
|
378
|
+
resets: ['nonexistent', 'also-fake'],
|
|
379
|
+
}],
|
|
380
|
+
default: 'skip',
|
|
381
|
+
});
|
|
382
|
+
// Should not throw
|
|
383
|
+
assert.strictEqual(gate.evaluate(event()).trigger, true);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// status reporting
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Hot reload: changed-behavior state invalidation
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
describe('hot reload — changed behavior clears stale state', () => {
|
|
393
|
+
it('rate_limit tokens lowered: bucket cleared, fresh quota at the new limit', async () => {
|
|
394
|
+
const h = makeFakeClockGate({
|
|
395
|
+
policies: [{
|
|
396
|
+
name: 'rl',
|
|
397
|
+
match: {},
|
|
398
|
+
behavior: { rate_limit: { tokens: 12, refillIntervalMs: 10_000 } },
|
|
399
|
+
}],
|
|
400
|
+
default: 'skip',
|
|
401
|
+
});
|
|
402
|
+
// Drain the old 12-token bucket
|
|
403
|
+
for (let i = 0; i < 12; i++) {
|
|
404
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true);
|
|
405
|
+
}
|
|
406
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false);
|
|
407
|
+
// Reload with tokens=3
|
|
408
|
+
await rewriteConfig(h, {
|
|
409
|
+
policies: [{
|
|
410
|
+
name: 'rl',
|
|
411
|
+
match: {},
|
|
412
|
+
behavior: { rate_limit: { tokens: 3, refillIntervalMs: 10_000 } },
|
|
413
|
+
}],
|
|
414
|
+
default: 'skip',
|
|
415
|
+
});
|
|
416
|
+
// Next evaluate triggers reload + fresh bucket at the new limit.
|
|
417
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true);
|
|
418
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true);
|
|
419
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true);
|
|
420
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false, '4th post-reload invocation must hit the new limit (tokens=3), not the old 12');
|
|
421
|
+
});
|
|
422
|
+
it('rate_limit refillIntervalMs changed: bucket cleared (state stale otherwise)', async () => {
|
|
423
|
+
const h = makeFakeClockGate({
|
|
424
|
+
policies: [{
|
|
425
|
+
name: 'rl',
|
|
426
|
+
match: {},
|
|
427
|
+
behavior: { rate_limit: { tokens: 5, refillIntervalMs: 100_000 } },
|
|
428
|
+
}],
|
|
429
|
+
default: 'skip',
|
|
430
|
+
});
|
|
431
|
+
// Drain
|
|
432
|
+
for (let i = 0; i < 5; i++)
|
|
433
|
+
h.gate.evaluate(event());
|
|
434
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false);
|
|
435
|
+
await rewriteConfig(h, {
|
|
436
|
+
policies: [{
|
|
437
|
+
name: 'rl',
|
|
438
|
+
match: {},
|
|
439
|
+
behavior: { rate_limit: { tokens: 5, refillIntervalMs: 1000 } },
|
|
440
|
+
}],
|
|
441
|
+
default: 'skip',
|
|
442
|
+
});
|
|
443
|
+
// Bucket was cleared; new 5 tokens available immediately.
|
|
444
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true);
|
|
445
|
+
});
|
|
446
|
+
it('rate_limit keyBy changed: old keyed buckets do NOT carry over', async () => {
|
|
447
|
+
const h = makeFakeClockGate({
|
|
448
|
+
policies: [{
|
|
449
|
+
name: 'rl',
|
|
450
|
+
match: {},
|
|
451
|
+
behavior: { rate_limit: { tokens: 1, refillIntervalMs: 100_000, keyBy: 'channelId' } },
|
|
452
|
+
}],
|
|
453
|
+
default: 'skip',
|
|
454
|
+
});
|
|
455
|
+
// Burn the only token under channel A
|
|
456
|
+
h.gate.evaluate(event({ metadata: { channelId: 'A', senderId: 'u' } }));
|
|
457
|
+
assert.strictEqual(h.gate.evaluate(event({ metadata: { channelId: 'A', senderId: 'u' } })).trigger, false);
|
|
458
|
+
// Reload — same tokens/refill, but keyBy switched to senderId.
|
|
459
|
+
await rewriteConfig(h, {
|
|
460
|
+
policies: [{
|
|
461
|
+
name: 'rl',
|
|
462
|
+
match: {},
|
|
463
|
+
behavior: { rate_limit: { tokens: 1, refillIntervalMs: 100_000, keyBy: 'senderId' } },
|
|
464
|
+
}],
|
|
465
|
+
default: 'skip',
|
|
466
|
+
});
|
|
467
|
+
// Sender u under the new scheme must get a fresh bucket — old
|
|
468
|
+
// channel-A bucket is gone, doesn't bleed into the new partition.
|
|
469
|
+
assert.strictEqual(h.gate.evaluate(event({ metadata: { channelId: 'A', senderId: 'u' } })).trigger, true);
|
|
470
|
+
});
|
|
471
|
+
it('passive_sample every lowered: counter cleared, does NOT fire stale', async () => {
|
|
472
|
+
const h = makeFakeClockGate({
|
|
473
|
+
policies: [{
|
|
474
|
+
name: 'ps',
|
|
475
|
+
match: {},
|
|
476
|
+
behavior: { passive_sample: { every: 10 } },
|
|
477
|
+
}],
|
|
478
|
+
default: 'skip',
|
|
479
|
+
});
|
|
480
|
+
// Pile up the counter to 7
|
|
481
|
+
for (let i = 0; i < 7; i++)
|
|
482
|
+
h.gate.evaluate(event());
|
|
483
|
+
// Reload to every=3. Without the fix, counter at 7+1=8 ≥ 3 → fires
|
|
484
|
+
// immediately on the very next event. The fix should clear it to 0,
|
|
485
|
+
// so we need 3 more events before it fires.
|
|
486
|
+
await rewriteConfig(h, {
|
|
487
|
+
policies: [{
|
|
488
|
+
name: 'ps',
|
|
489
|
+
match: {},
|
|
490
|
+
behavior: { passive_sample: { every: 3 } },
|
|
491
|
+
}],
|
|
492
|
+
default: 'skip',
|
|
493
|
+
});
|
|
494
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false, '1st post-reload');
|
|
495
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false, '2nd post-reload');
|
|
496
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, true, '3rd post-reload fires');
|
|
497
|
+
});
|
|
498
|
+
it('passive_sample keyBy changed: per-key counters cleared', async () => {
|
|
499
|
+
const h = makeFakeClockGate({
|
|
500
|
+
policies: [{
|
|
501
|
+
name: 'ps',
|
|
502
|
+
match: {},
|
|
503
|
+
behavior: { passive_sample: { every: 3, keyBy: 'channelId' } },
|
|
504
|
+
}],
|
|
505
|
+
default: 'skip',
|
|
506
|
+
});
|
|
507
|
+
// Two messages in channel A — counter at 2.
|
|
508
|
+
for (let i = 0; i < 2; i++) {
|
|
509
|
+
h.gate.evaluate(event({ metadata: { channelId: 'A' } }));
|
|
510
|
+
}
|
|
511
|
+
await rewriteConfig(h, {
|
|
512
|
+
policies: [{
|
|
513
|
+
name: 'ps',
|
|
514
|
+
match: {},
|
|
515
|
+
behavior: { passive_sample: { every: 3, keyBy: 'senderId' } },
|
|
516
|
+
}],
|
|
517
|
+
default: 'skip',
|
|
518
|
+
});
|
|
519
|
+
// First post-reload event under sender X — fresh counter (1, not 3).
|
|
520
|
+
assert.strictEqual(h.gate.evaluate(event({ metadata: { senderId: 'X' } })).trigger, false);
|
|
521
|
+
});
|
|
522
|
+
it('unchanged behavior on reload: state preserved', async () => {
|
|
523
|
+
const config = {
|
|
524
|
+
policies: [{
|
|
525
|
+
name: 'rl',
|
|
526
|
+
match: {},
|
|
527
|
+
behavior: { rate_limit: { tokens: 3, refillIntervalMs: 100_000 } },
|
|
528
|
+
}],
|
|
529
|
+
default: 'skip',
|
|
530
|
+
};
|
|
531
|
+
const h = makeFakeClockGate(config);
|
|
532
|
+
// Drain to 0
|
|
533
|
+
for (let i = 0; i < 3; i++)
|
|
534
|
+
h.gate.evaluate(event());
|
|
535
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false);
|
|
536
|
+
// Reload with identical behavior (e.g., a stylistic edit elsewhere
|
|
537
|
+
// in the same file). State must NOT be cleared.
|
|
538
|
+
await rewriteConfig(h, config);
|
|
539
|
+
assert.strictEqual(h.gate.evaluate(event()).trigger, false, 'unchanged-behavior reload must not regrant tokens');
|
|
540
|
+
});
|
|
541
|
+
it('trace event lists policies whose state was cleared', async () => {
|
|
542
|
+
const h = makeFakeClockGate({
|
|
543
|
+
policies: [
|
|
544
|
+
{ name: 'a', match: {}, behavior: { rate_limit: { tokens: 5, refillIntervalMs: 1000 } } },
|
|
545
|
+
{ name: 'b', match: {}, behavior: { passive_sample: { every: 5 } } },
|
|
546
|
+
],
|
|
547
|
+
default: 'skip',
|
|
548
|
+
});
|
|
549
|
+
// change one behavior, leave the other alone
|
|
550
|
+
await rewriteConfig(h, {
|
|
551
|
+
policies: [
|
|
552
|
+
{ name: 'a', match: {}, behavior: { rate_limit: { tokens: 2, refillIntervalMs: 1000 } } },
|
|
553
|
+
{ name: 'b', match: {}, behavior: { passive_sample: { every: 5 } } },
|
|
554
|
+
],
|
|
555
|
+
default: 'skip',
|
|
556
|
+
});
|
|
557
|
+
h.gate.evaluate(event()); // triggers reload
|
|
558
|
+
const reload = h.traces.find(t => t.type === 'gate:config-reloaded');
|
|
559
|
+
assert.ok(reload, 'should have emitted a reload trace');
|
|
560
|
+
assert.deepStrictEqual(reload.stateClearedPolicies, ['a']);
|
|
561
|
+
assert.strictEqual(reload.removedPolicies, undefined);
|
|
562
|
+
});
|
|
563
|
+
it('trace event lists removed policies separately from state-cleared ones', async () => {
|
|
564
|
+
const h = makeFakeClockGate({
|
|
565
|
+
policies: [
|
|
566
|
+
{ name: 'kept', match: {}, behavior: { rate_limit: { tokens: 5, refillIntervalMs: 1000 } } },
|
|
567
|
+
{ name: 'gone', match: {}, behavior: { passive_sample: { every: 5 } } },
|
|
568
|
+
],
|
|
569
|
+
default: 'skip',
|
|
570
|
+
});
|
|
571
|
+
await rewriteConfig(h, {
|
|
572
|
+
policies: [
|
|
573
|
+
// change kept's behavior so it's also state-cleared
|
|
574
|
+
{ name: 'kept', match: {}, behavior: { rate_limit: { tokens: 1, refillIntervalMs: 1000 } } },
|
|
575
|
+
],
|
|
576
|
+
default: 'skip',
|
|
577
|
+
});
|
|
578
|
+
h.gate.evaluate(event());
|
|
579
|
+
const reload = h.traces.find(t => t.type === 'gate:config-reloaded');
|
|
580
|
+
assert.ok(reload);
|
|
581
|
+
assert.deepStrictEqual(reload.removedPolicies, ['gone']);
|
|
582
|
+
assert.deepStrictEqual(reload.stateClearedPolicies, ['kept']);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// status reporting
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
describe('gate:status with new behaviors', () => {
|
|
589
|
+
it('exposes rate-limit bucketCount + deniedCount', () => {
|
|
590
|
+
const { gate } = makeFakeClockGate({
|
|
591
|
+
policies: [{
|
|
592
|
+
name: 'rl',
|
|
593
|
+
match: {},
|
|
594
|
+
behavior: { rate_limit: { tokens: 1, refillIntervalMs: 10_000, keyBy: 'k' } },
|
|
595
|
+
}],
|
|
596
|
+
default: 'skip',
|
|
597
|
+
});
|
|
598
|
+
gate.evaluate(event({ metadata: { k: 'a' } })); // creates bucket A, consumes
|
|
599
|
+
gate.evaluate(event({ metadata: { k: 'b' } })); // creates bucket B, consumes
|
|
600
|
+
gate.evaluate(event({ metadata: { k: 'a' } })); // denied
|
|
601
|
+
const status = gate.getStatus();
|
|
602
|
+
const policy = status.policies.find(p => p.name === 'rl');
|
|
603
|
+
assert.ok(policy);
|
|
604
|
+
assert.strictEqual(policy.rateLimitState?.bucketCount, 2);
|
|
605
|
+
assert.strictEqual(policy.rateLimitState?.deniedCount, 1);
|
|
606
|
+
});
|
|
607
|
+
it('exposes passive-sample counterCount + fireCount', () => {
|
|
608
|
+
const { gate } = makeFakeClockGate({
|
|
609
|
+
policies: [{
|
|
610
|
+
name: 'ps',
|
|
611
|
+
match: {},
|
|
612
|
+
behavior: { passive_sample: { every: 2, keyBy: 'k' } },
|
|
613
|
+
}],
|
|
614
|
+
default: 'skip',
|
|
615
|
+
});
|
|
616
|
+
gate.evaluate(event({ metadata: { k: 'A' } })); // count 1
|
|
617
|
+
gate.evaluate(event({ metadata: { k: 'A' } })); // fires; reset to 0
|
|
618
|
+
gate.evaluate(event({ metadata: { k: 'B' } })); // count 1 in B
|
|
619
|
+
const status = gate.getStatus();
|
|
620
|
+
const policy = status.policies.find(p => p.name === 'ps');
|
|
621
|
+
assert.ok(policy);
|
|
622
|
+
// 2 keys touched (A and B), 1 fire so far
|
|
623
|
+
assert.strictEqual(policy.passiveSampleState?.counterCount, 2);
|
|
624
|
+
assert.strictEqual(policy.passiveSampleState?.fireCount, 1);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
//# sourceMappingURL=event-gate-rate-limit.test.js.map
|