@renseiai/agentfactory-server 0.8.0
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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/src/a2a-server.d.ts +88 -0
- package/dist/src/a2a-server.d.ts.map +1 -0
- package/dist/src/a2a-server.integration.test.d.ts +9 -0
- package/dist/src/a2a-server.integration.test.d.ts.map +1 -0
- package/dist/src/a2a-server.integration.test.js +397 -0
- package/dist/src/a2a-server.js +235 -0
- package/dist/src/a2a-server.test.d.ts +2 -0
- package/dist/src/a2a-server.test.d.ts.map +1 -0
- package/dist/src/a2a-server.test.js +311 -0
- package/dist/src/a2a-types.d.ts +125 -0
- package/dist/src/a2a-types.d.ts.map +1 -0
- package/dist/src/a2a-types.js +8 -0
- package/dist/src/agent-tracking.d.ts +201 -0
- package/dist/src/agent-tracking.d.ts.map +1 -0
- package/dist/src/agent-tracking.js +349 -0
- package/dist/src/env-validation.d.ts +65 -0
- package/dist/src/env-validation.d.ts.map +1 -0
- package/dist/src/env-validation.js +134 -0
- package/dist/src/governor-dedup.d.ts +15 -0
- package/dist/src/governor-dedup.d.ts.map +1 -0
- package/dist/src/governor-dedup.js +31 -0
- package/dist/src/governor-event-bus.d.ts +54 -0
- package/dist/src/governor-event-bus.d.ts.map +1 -0
- package/dist/src/governor-event-bus.js +152 -0
- package/dist/src/governor-storage.d.ts +28 -0
- package/dist/src/governor-storage.d.ts.map +1 -0
- package/dist/src/governor-storage.js +52 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +50 -0
- package/dist/src/issue-lock.d.ts +129 -0
- package/dist/src/issue-lock.d.ts.map +1 -0
- package/dist/src/issue-lock.js +508 -0
- package/dist/src/logger.d.ts +76 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +218 -0
- package/dist/src/orphan-cleanup.d.ts +64 -0
- package/dist/src/orphan-cleanup.d.ts.map +1 -0
- package/dist/src/orphan-cleanup.js +369 -0
- package/dist/src/pending-prompts.d.ts +67 -0
- package/dist/src/pending-prompts.d.ts.map +1 -0
- package/dist/src/pending-prompts.js +176 -0
- package/dist/src/processing-state-storage.d.ts +38 -0
- package/dist/src/processing-state-storage.d.ts.map +1 -0
- package/dist/src/processing-state-storage.js +61 -0
- package/dist/src/quota-tracker.d.ts +62 -0
- package/dist/src/quota-tracker.d.ts.map +1 -0
- package/dist/src/quota-tracker.js +155 -0
- package/dist/src/rate-limit.d.ts +111 -0
- package/dist/src/rate-limit.d.ts.map +1 -0
- package/dist/src/rate-limit.js +171 -0
- package/dist/src/redis-circuit-breaker.d.ts +67 -0
- package/dist/src/redis-circuit-breaker.d.ts.map +1 -0
- package/dist/src/redis-circuit-breaker.js +290 -0
- package/dist/src/redis-rate-limiter.d.ts +51 -0
- package/dist/src/redis-rate-limiter.d.ts.map +1 -0
- package/dist/src/redis-rate-limiter.js +168 -0
- package/dist/src/redis.d.ts +146 -0
- package/dist/src/redis.d.ts.map +1 -0
- package/dist/src/redis.js +343 -0
- package/dist/src/session-hash.d.ts +48 -0
- package/dist/src/session-hash.d.ts.map +1 -0
- package/dist/src/session-hash.js +80 -0
- package/dist/src/session-storage.d.ts +166 -0
- package/dist/src/session-storage.d.ts.map +1 -0
- package/dist/src/session-storage.js +397 -0
- package/dist/src/token-storage.d.ts +118 -0
- package/dist/src/token-storage.d.ts.map +1 -0
- package/dist/src/token-storage.js +263 -0
- package/dist/src/types.d.ts +11 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +7 -0
- package/dist/src/webhook-idempotency.d.ts +44 -0
- package/dist/src/webhook-idempotency.d.ts.map +1 -0
- package/dist/src/webhook-idempotency.js +148 -0
- package/dist/src/work-queue.d.ts +120 -0
- package/dist/src/work-queue.d.ts.map +1 -0
- package/dist/src/work-queue.js +384 -0
- package/dist/src/worker-auth.d.ts +29 -0
- package/dist/src/worker-auth.d.ts.map +1 -0
- package/dist/src/worker-auth.js +49 -0
- package/dist/src/worker-storage.d.ts +108 -0
- package/dist/src/worker-storage.d.ts.map +1 -0
- package/dist/src/worker-storage.js +295 -0
- package/dist/src/workflow-state-integration.test.d.ts +2 -0
- package/dist/src/workflow-state-integration.test.d.ts.map +1 -0
- package/dist/src/workflow-state-integration.test.js +342 -0
- package/dist/src/workflow-state.test.d.ts +2 -0
- package/dist/src/workflow-state.test.d.ts.map +1 -0
- package/dist/src/workflow-state.test.js +113 -0
- package/package.json +72 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Circuit Breaker
|
|
3
|
+
*
|
|
4
|
+
* Shares circuit breaker state across processes via Redis.
|
|
5
|
+
* All processes (dashboard, governor, CLI agents) see the same
|
|
6
|
+
* circuit state for a workspace.
|
|
7
|
+
*
|
|
8
|
+
* Redis keys:
|
|
9
|
+
* - `linear:circuit:{workspaceId}:state` — 'closed' | 'open' | 'half-open'
|
|
10
|
+
* - `linear:circuit:{workspaceId}:failures` — consecutive failure count (with TTL)
|
|
11
|
+
* - `linear:circuit:{workspaceId}:opened_at` — timestamp when circuit was opened
|
|
12
|
+
* - `linear:circuit:{workspaceId}:reset_timeout` — current reset timeout (for backoff)
|
|
13
|
+
*
|
|
14
|
+
* Implements CircuitBreakerStrategy from @renseiai/agentfactory-linear
|
|
15
|
+
* so it can be injected into LinearAgentClient.
|
|
16
|
+
*/
|
|
17
|
+
import { getRedisClient } from './redis.js';
|
|
18
|
+
import { createLogger } from './logger.js';
|
|
19
|
+
const log = createLogger('redis-circuit-breaker');
|
|
20
|
+
const DEFAULT_CONFIG = {
|
|
21
|
+
failureThreshold: 2,
|
|
22
|
+
resetTimeoutMs: 60_000,
|
|
23
|
+
maxResetTimeoutMs: 300_000,
|
|
24
|
+
backoffMultiplier: 2,
|
|
25
|
+
authErrorCodes: [400, 401, 403],
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Lua script for atomic circuit breaker state check.
|
|
29
|
+
*
|
|
30
|
+
* KEYS[1] = state key
|
|
31
|
+
* KEYS[2] = opened_at key
|
|
32
|
+
* KEYS[3] = reset_timeout key
|
|
33
|
+
* ARGV[1] = current timestamp (ms)
|
|
34
|
+
* ARGV[2] = default reset timeout (ms)
|
|
35
|
+
*
|
|
36
|
+
* Returns: 1 if call can proceed, 0 if blocked, 2 if probe (half-open)
|
|
37
|
+
*/
|
|
38
|
+
const CAN_PROCEED_LUA = `
|
|
39
|
+
local stateKey = KEYS[1]
|
|
40
|
+
local openedAtKey = KEYS[2]
|
|
41
|
+
local resetTimeoutKey = KEYS[3]
|
|
42
|
+
local now = tonumber(ARGV[1])
|
|
43
|
+
local defaultResetTimeout = tonumber(ARGV[2])
|
|
44
|
+
|
|
45
|
+
local state = redis.call('GET', stateKey)
|
|
46
|
+
|
|
47
|
+
-- Closed or no state: allow
|
|
48
|
+
if state == false or state == 'closed' then
|
|
49
|
+
return 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
-- Open: check if reset timeout has elapsed
|
|
53
|
+
if state == 'open' then
|
|
54
|
+
local openedAt = tonumber(redis.call('GET', openedAtKey)) or 0
|
|
55
|
+
local resetTimeout = tonumber(redis.call('GET', resetTimeoutKey)) or defaultResetTimeout
|
|
56
|
+
|
|
57
|
+
if (now - openedAt) >= resetTimeout then
|
|
58
|
+
-- Transition to half-open: allow one probe
|
|
59
|
+
redis.call('SET', stateKey, 'half-open', 'EX', 3600)
|
|
60
|
+
return 2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
-- Half-open: block (probe already in flight)
|
|
67
|
+
-- The first caller to see 'open' -> 'half-open' transition gets the probe
|
|
68
|
+
if state == 'half-open' then
|
|
69
|
+
return 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return 1
|
|
73
|
+
`;
|
|
74
|
+
/**
|
|
75
|
+
* Lua script for recording auth failure.
|
|
76
|
+
*
|
|
77
|
+
* KEYS[1] = state key
|
|
78
|
+
* KEYS[2] = failures key
|
|
79
|
+
* KEYS[3] = opened_at key
|
|
80
|
+
* KEYS[4] = reset_timeout key
|
|
81
|
+
* ARGV[1] = failure threshold
|
|
82
|
+
* ARGV[2] = current timestamp (ms)
|
|
83
|
+
* ARGV[3] = default reset timeout (ms)
|
|
84
|
+
* ARGV[4] = backoff multiplier
|
|
85
|
+
* ARGV[5] = max reset timeout (ms)
|
|
86
|
+
*
|
|
87
|
+
* Returns: new state ('closed', 'open')
|
|
88
|
+
*/
|
|
89
|
+
const RECORD_FAILURE_LUA = `
|
|
90
|
+
local stateKey = KEYS[1]
|
|
91
|
+
local failuresKey = KEYS[2]
|
|
92
|
+
local openedAtKey = KEYS[3]
|
|
93
|
+
local resetTimeoutKey = KEYS[4]
|
|
94
|
+
local threshold = tonumber(ARGV[1])
|
|
95
|
+
local now = tonumber(ARGV[2])
|
|
96
|
+
local defaultResetTimeout = tonumber(ARGV[3])
|
|
97
|
+
local backoffMultiplier = tonumber(ARGV[4])
|
|
98
|
+
local maxResetTimeout = tonumber(ARGV[5])
|
|
99
|
+
|
|
100
|
+
local state = redis.call('GET', stateKey) or 'closed'
|
|
101
|
+
local failures = tonumber(redis.call('INCR', failuresKey))
|
|
102
|
+
redis.call('EXPIRE', failuresKey, 3600)
|
|
103
|
+
|
|
104
|
+
-- If half-open: probe failed, reopen with backoff
|
|
105
|
+
if state == 'half-open' then
|
|
106
|
+
local currentTimeout = tonumber(redis.call('GET', resetTimeoutKey)) or defaultResetTimeout
|
|
107
|
+
local newTimeout = math.min(currentTimeout * backoffMultiplier, maxResetTimeout)
|
|
108
|
+
redis.call('SET', stateKey, 'open', 'EX', 3600)
|
|
109
|
+
redis.call('SET', openedAtKey, tostring(now), 'EX', 3600)
|
|
110
|
+
redis.call('SET', resetTimeoutKey, tostring(newTimeout), 'EX', 3600)
|
|
111
|
+
return 'open'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
-- If closed and at threshold: trip to open
|
|
115
|
+
if failures >= threshold then
|
|
116
|
+
redis.call('SET', stateKey, 'open', 'EX', 3600)
|
|
117
|
+
redis.call('SET', openedAtKey, tostring(now), 'EX', 3600)
|
|
118
|
+
redis.call('SET', resetTimeoutKey, tostring(defaultResetTimeout), 'EX', 3600)
|
|
119
|
+
return 'open'
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return 'closed'
|
|
123
|
+
`;
|
|
124
|
+
export class RedisCircuitBreaker {
|
|
125
|
+
config;
|
|
126
|
+
keyPrefix;
|
|
127
|
+
constructor(config) {
|
|
128
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
129
|
+
this.keyPrefix = `linear:circuit:${this.config.workspaceId}`;
|
|
130
|
+
}
|
|
131
|
+
get stateKey() {
|
|
132
|
+
return `${this.keyPrefix}:state`;
|
|
133
|
+
}
|
|
134
|
+
get failuresKey() {
|
|
135
|
+
return `${this.keyPrefix}:failures`;
|
|
136
|
+
}
|
|
137
|
+
get openedAtKey() {
|
|
138
|
+
return `${this.keyPrefix}:opened_at`;
|
|
139
|
+
}
|
|
140
|
+
get resetTimeoutKey() {
|
|
141
|
+
return `${this.keyPrefix}:reset_timeout`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Check if a call is allowed to proceed.
|
|
145
|
+
*/
|
|
146
|
+
async canProceed() {
|
|
147
|
+
try {
|
|
148
|
+
const redis = getRedisClient();
|
|
149
|
+
const result = await redis.eval(CAN_PROCEED_LUA, 3, this.stateKey, this.openedAtKey, this.resetTimeoutKey, String(Date.now()), String(this.config.resetTimeoutMs));
|
|
150
|
+
// 1 = closed (allow), 2 = half-open probe (allow), 0 = blocked
|
|
151
|
+
return result === 1 || result === 2;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
// If Redis is down, allow the request (fail open for circuit breaker)
|
|
155
|
+
log.error('Redis circuit breaker error, failing open', {
|
|
156
|
+
error: err instanceof Error ? err.message : String(err),
|
|
157
|
+
});
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Record a successful API call. Resets the circuit to closed.
|
|
163
|
+
*/
|
|
164
|
+
async recordSuccess() {
|
|
165
|
+
try {
|
|
166
|
+
const redis = getRedisClient();
|
|
167
|
+
const pipeline = redis.pipeline();
|
|
168
|
+
pipeline.set(this.stateKey, 'closed', 'EX', 3600);
|
|
169
|
+
pipeline.del(this.failuresKey);
|
|
170
|
+
pipeline.del(this.openedAtKey);
|
|
171
|
+
pipeline.del(this.resetTimeoutKey);
|
|
172
|
+
await pipeline.exec();
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
log.error('Failed to record circuit breaker success', {
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Record an auth failure. May trip the circuit to open.
|
|
182
|
+
*/
|
|
183
|
+
async recordAuthFailure(_statusCode) {
|
|
184
|
+
try {
|
|
185
|
+
const redis = getRedisClient();
|
|
186
|
+
const result = await redis.eval(RECORD_FAILURE_LUA, 4, this.stateKey, this.failuresKey, this.openedAtKey, this.resetTimeoutKey, String(this.config.failureThreshold), String(Date.now()), String(this.config.resetTimeoutMs), String(this.config.backoffMultiplier), String(this.config.maxResetTimeoutMs));
|
|
187
|
+
if (result === 'open') {
|
|
188
|
+
log.warn('Circuit breaker tripped to OPEN', {
|
|
189
|
+
workspaceId: this.config.workspaceId,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
log.error('Failed to record circuit breaker failure', {
|
|
195
|
+
error: err instanceof Error ? err.message : String(err),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Check if an error is an auth/rate-limit error.
|
|
201
|
+
* Reuses the same detection logic as the in-memory CircuitBreaker.
|
|
202
|
+
*/
|
|
203
|
+
isAuthError(error) {
|
|
204
|
+
if (typeof error !== 'object' || error === null)
|
|
205
|
+
return false;
|
|
206
|
+
const err = error;
|
|
207
|
+
// Check HTTP status code
|
|
208
|
+
const statusCode = (typeof err.status === 'number' ? err.status : undefined) ??
|
|
209
|
+
(typeof err.statusCode === 'number' ? err.statusCode : undefined) ??
|
|
210
|
+
(typeof err.response?.status === 'number'
|
|
211
|
+
? err.response.status
|
|
212
|
+
: undefined);
|
|
213
|
+
if (statusCode !== undefined && this.config.authErrorCodes.includes(statusCode)) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
// Check for GraphQL RATELIMITED
|
|
217
|
+
const extensions = err.extensions;
|
|
218
|
+
if (extensions?.code === 'RATELIMITED')
|
|
219
|
+
return true;
|
|
220
|
+
const errors = err.errors;
|
|
221
|
+
if (Array.isArray(errors)) {
|
|
222
|
+
for (const gqlError of errors) {
|
|
223
|
+
const ext = gqlError.extensions;
|
|
224
|
+
if (ext?.code === 'RATELIMITED')
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Check error message patterns
|
|
229
|
+
const message = err.message ?? '';
|
|
230
|
+
if (/access denied|unauthorized|forbidden|RATELIMITED/i.test(message)) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Reset the circuit breaker to closed state.
|
|
237
|
+
*/
|
|
238
|
+
async reset() {
|
|
239
|
+
try {
|
|
240
|
+
const redis = getRedisClient();
|
|
241
|
+
const pipeline = redis.pipeline();
|
|
242
|
+
pipeline.set(this.stateKey, 'closed', 'EX', 3600);
|
|
243
|
+
pipeline.del(this.failuresKey);
|
|
244
|
+
pipeline.del(this.openedAtKey);
|
|
245
|
+
pipeline.del(this.resetTimeoutKey);
|
|
246
|
+
await pipeline.exec();
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
log.error('Failed to reset circuit breaker', {
|
|
250
|
+
error: err instanceof Error ? err.message : String(err),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get diagnostic info for monitoring.
|
|
256
|
+
*/
|
|
257
|
+
async getStatus() {
|
|
258
|
+
try {
|
|
259
|
+
const redis = getRedisClient();
|
|
260
|
+
const [state, failures, openedAt, resetTimeout] = await Promise.all([
|
|
261
|
+
redis.get(this.stateKey),
|
|
262
|
+
redis.get(this.failuresKey),
|
|
263
|
+
redis.get(this.openedAtKey),
|
|
264
|
+
redis.get(this.resetTimeoutKey),
|
|
265
|
+
]);
|
|
266
|
+
return {
|
|
267
|
+
state: state ?? 'closed',
|
|
268
|
+
failures: failures ? parseInt(failures, 10) : 0,
|
|
269
|
+
openedAt: openedAt ? parseInt(openedAt, 10) : null,
|
|
270
|
+
currentResetTimeoutMs: resetTimeout
|
|
271
|
+
? parseInt(resetTimeout, 10)
|
|
272
|
+
: this.config.resetTimeoutMs,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return {
|
|
277
|
+
state: 'unknown',
|
|
278
|
+
failures: -1,
|
|
279
|
+
openedAt: null,
|
|
280
|
+
currentResetTimeoutMs: this.config.resetTimeoutMs,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Create a Redis circuit breaker for a specific workspace.
|
|
287
|
+
*/
|
|
288
|
+
export function createRedisCircuitBreaker(workspaceId, config) {
|
|
289
|
+
return new RedisCircuitBreaker({ ...config, workspaceId });
|
|
290
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Token Bucket Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Atomic token bucket implementation using Redis + Lua script.
|
|
5
|
+
* All processes (dashboard, governor, CLI agents) share one bucket
|
|
6
|
+
* keyed by `linear:rate-limit:{workspaceId}`.
|
|
7
|
+
*
|
|
8
|
+
* Implements RateLimiterStrategy from @renseiai/agentfactory-linear
|
|
9
|
+
* so it can be injected into LinearAgentClient.
|
|
10
|
+
*/
|
|
11
|
+
import type { RateLimiterStrategy } from '@renseiai/agentfactory-linear';
|
|
12
|
+
export interface RedisTokenBucketConfig {
|
|
13
|
+
/** Redis key for this bucket (default: 'linear:rate-limit:default') */
|
|
14
|
+
key: string;
|
|
15
|
+
/** Maximum tokens (burst capacity). Default: 80 */
|
|
16
|
+
maxTokens: number;
|
|
17
|
+
/** Tokens added per second. Default: 1.5 (~90/min) */
|
|
18
|
+
refillRate: number;
|
|
19
|
+
/** Maximum time to wait for a token before throwing (ms). Default: 30_000 */
|
|
20
|
+
acquireTimeoutMs: number;
|
|
21
|
+
/** Polling interval when waiting for tokens (ms). Default: 500 */
|
|
22
|
+
pollIntervalMs: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_REDIS_RATE_LIMIT_CONFIG: RedisTokenBucketConfig;
|
|
25
|
+
export declare class RedisTokenBucket implements RateLimiterStrategy {
|
|
26
|
+
private readonly config;
|
|
27
|
+
constructor(config?: Partial<RedisTokenBucketConfig>);
|
|
28
|
+
/**
|
|
29
|
+
* Acquire a single token. Polls Redis until a token is available
|
|
30
|
+
* or the acquire timeout is reached.
|
|
31
|
+
*/
|
|
32
|
+
acquire(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Penalize the bucket after receiving a rate limit response.
|
|
35
|
+
* Drains all tokens and sets a penalty period.
|
|
36
|
+
*/
|
|
37
|
+
penalize(seconds: number): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Try to acquire a token atomically. Returns true if acquired.
|
|
40
|
+
*/
|
|
41
|
+
private tryAcquire;
|
|
42
|
+
/**
|
|
43
|
+
* Get the current token count (for monitoring).
|
|
44
|
+
*/
|
|
45
|
+
getAvailableTokens(): Promise<number>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Create a Redis token bucket for a specific workspace.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createRedisTokenBucket(workspaceId: string, config?: Partial<Omit<RedisTokenBucketConfig, 'key'>>): RedisTokenBucket;
|
|
51
|
+
//# sourceMappingURL=redis-rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/redis-rate-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAA;AAIxE,MAAM,WAAW,sBAAsB;IACrC,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAA;IACX,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAA;IAClB,6EAA6E;IAC7E,gBAAgB,EAAE,MAAM,CAAA;IACxB,kEAAkE;IAClE,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,eAAO,MAAM,+BAA+B,EAAE,sBAM7C,CAAA;AAyED,qBAAa,gBAAiB,YAAW,mBAAmB;IAC1D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwB;gBAEnC,MAAM,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC;IAIpD;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB9B;;;OAGG;IACG,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB9C;;OAEG;YACW,UAAU;IAqBxB;;OAEG;IACG,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC;CAS5C;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC,GACpD,gBAAgB,CAKlB"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Token Bucket Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Atomic token bucket implementation using Redis + Lua script.
|
|
5
|
+
* All processes (dashboard, governor, CLI agents) share one bucket
|
|
6
|
+
* keyed by `linear:rate-limit:{workspaceId}`.
|
|
7
|
+
*
|
|
8
|
+
* Implements RateLimiterStrategy from @renseiai/agentfactory-linear
|
|
9
|
+
* so it can be injected into LinearAgentClient.
|
|
10
|
+
*/
|
|
11
|
+
import { getRedisClient } from './redis.js';
|
|
12
|
+
import { createLogger } from './logger.js';
|
|
13
|
+
const log = createLogger('redis-rate-limiter');
|
|
14
|
+
export const DEFAULT_REDIS_RATE_LIMIT_CONFIG = {
|
|
15
|
+
key: 'linear:rate-limit:default',
|
|
16
|
+
maxTokens: 80,
|
|
17
|
+
refillRate: 1.5,
|
|
18
|
+
acquireTimeoutMs: 30_000,
|
|
19
|
+
pollIntervalMs: 500,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Lua script for atomic token bucket acquire.
|
|
23
|
+
*
|
|
24
|
+
* KEYS[1] = bucket key (hash with fields: tokens, last_refill, penalty_until)
|
|
25
|
+
* ARGV[1] = maxTokens
|
|
26
|
+
* ARGV[2] = refillRate (tokens per second)
|
|
27
|
+
* ARGV[3] = current timestamp (ms)
|
|
28
|
+
*
|
|
29
|
+
* Returns: 1 if token acquired, 0 if no tokens available
|
|
30
|
+
*/
|
|
31
|
+
const ACQUIRE_LUA = `
|
|
32
|
+
local key = KEYS[1]
|
|
33
|
+
local maxTokens = tonumber(ARGV[1])
|
|
34
|
+
local refillRate = tonumber(ARGV[2])
|
|
35
|
+
local now = tonumber(ARGV[3])
|
|
36
|
+
|
|
37
|
+
-- Initialize bucket if it doesn't exist
|
|
38
|
+
local tokens = tonumber(redis.call('HGET', key, 'tokens'))
|
|
39
|
+
local lastRefill = tonumber(redis.call('HGET', key, 'last_refill'))
|
|
40
|
+
local penaltyUntil = tonumber(redis.call('HGET', key, 'penalty_until')) or 0
|
|
41
|
+
|
|
42
|
+
if tokens == nil then
|
|
43
|
+
tokens = maxTokens
|
|
44
|
+
lastRefill = now
|
|
45
|
+
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', lastRefill, 'penalty_until', 0)
|
|
46
|
+
redis.call('EXPIRE', key, 3600)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
-- Check if we're in a penalty period
|
|
50
|
+
if now < penaltyUntil then
|
|
51
|
+
return 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
-- Refill tokens based on elapsed time
|
|
55
|
+
local elapsed = (now - lastRefill) / 1000.0
|
|
56
|
+
if elapsed > 0 then
|
|
57
|
+
local newTokens = elapsed * refillRate
|
|
58
|
+
tokens = math.min(maxTokens, tokens + newTokens)
|
|
59
|
+
lastRefill = now
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Try to acquire a token
|
|
63
|
+
if tokens >= 1 then
|
|
64
|
+
tokens = tokens - 1
|
|
65
|
+
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', lastRefill)
|
|
66
|
+
redis.call('EXPIRE', key, 3600)
|
|
67
|
+
return 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', lastRefill)
|
|
71
|
+
redis.call('EXPIRE', key, 3600)
|
|
72
|
+
return 0
|
|
73
|
+
`;
|
|
74
|
+
/**
|
|
75
|
+
* Lua script for penalizing the bucket (after rate limit response).
|
|
76
|
+
*
|
|
77
|
+
* KEYS[1] = bucket key
|
|
78
|
+
* ARGV[1] = penalty duration (seconds)
|
|
79
|
+
* ARGV[2] = current timestamp (ms)
|
|
80
|
+
*/
|
|
81
|
+
const PENALIZE_LUA = `
|
|
82
|
+
local key = KEYS[1]
|
|
83
|
+
local penaltySeconds = tonumber(ARGV[1])
|
|
84
|
+
local now = tonumber(ARGV[2])
|
|
85
|
+
|
|
86
|
+
redis.call('HMSET', key, 'tokens', 0, 'penalty_until', now + (penaltySeconds * 1000))
|
|
87
|
+
redis.call('EXPIRE', key, 3600)
|
|
88
|
+
return 1
|
|
89
|
+
`;
|
|
90
|
+
export class RedisTokenBucket {
|
|
91
|
+
config;
|
|
92
|
+
constructor(config) {
|
|
93
|
+
this.config = { ...DEFAULT_REDIS_RATE_LIMIT_CONFIG, ...config };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Acquire a single token. Polls Redis until a token is available
|
|
97
|
+
* or the acquire timeout is reached.
|
|
98
|
+
*/
|
|
99
|
+
async acquire() {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
while (true) {
|
|
102
|
+
const acquired = await this.tryAcquire();
|
|
103
|
+
if (acquired)
|
|
104
|
+
return;
|
|
105
|
+
// Check timeout
|
|
106
|
+
if (Date.now() - start > this.config.acquireTimeoutMs) {
|
|
107
|
+
throw new Error(`RedisTokenBucket: timed out waiting for rate limit token after ${this.config.acquireTimeoutMs}ms`);
|
|
108
|
+
}
|
|
109
|
+
// Wait before polling again
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, this.config.pollIntervalMs));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Penalize the bucket after receiving a rate limit response.
|
|
115
|
+
* Drains all tokens and sets a penalty period.
|
|
116
|
+
*/
|
|
117
|
+
async penalize(seconds) {
|
|
118
|
+
try {
|
|
119
|
+
const redis = getRedisClient();
|
|
120
|
+
await redis.eval(PENALIZE_LUA, 1, this.config.key, String(seconds), String(Date.now()));
|
|
121
|
+
log.warn('Rate limit penalty applied', { seconds, key: this.config.key });
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
log.error('Failed to apply rate limit penalty', {
|
|
125
|
+
error: err instanceof Error ? err.message : String(err),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Try to acquire a token atomically. Returns true if acquired.
|
|
131
|
+
*/
|
|
132
|
+
async tryAcquire() {
|
|
133
|
+
try {
|
|
134
|
+
const redis = getRedisClient();
|
|
135
|
+
const result = await redis.eval(ACQUIRE_LUA, 1, this.config.key, String(this.config.maxTokens), String(this.config.refillRate), String(Date.now()));
|
|
136
|
+
return result === 1;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// If Redis is down, allow the request (fail open for rate limiting)
|
|
140
|
+
log.error('Redis rate limiter error, failing open', {
|
|
141
|
+
error: err instanceof Error ? err.message : String(err),
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Get the current token count (for monitoring).
|
|
148
|
+
*/
|
|
149
|
+
async getAvailableTokens() {
|
|
150
|
+
try {
|
|
151
|
+
const redis = getRedisClient();
|
|
152
|
+
const tokens = await redis.hget(this.config.key, 'tokens');
|
|
153
|
+
return tokens ? parseFloat(tokens) : this.config.maxTokens;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return -1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Create a Redis token bucket for a specific workspace.
|
|
162
|
+
*/
|
|
163
|
+
export function createRedisTokenBucket(workspaceId, config) {
|
|
164
|
+
return new RedisTokenBucket({
|
|
165
|
+
...config,
|
|
166
|
+
key: `linear:rate-limit:${workspaceId}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
/**
|
|
3
|
+
* Check if Redis is configured via REDIS_URL
|
|
4
|
+
*/
|
|
5
|
+
export declare function isRedisConfigured(): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Get the shared Redis client instance
|
|
8
|
+
* Lazily initialized to avoid errors during build
|
|
9
|
+
*/
|
|
10
|
+
export declare function getRedisClient(): Redis;
|
|
11
|
+
/**
|
|
12
|
+
* Disconnect Redis client (for graceful shutdown)
|
|
13
|
+
*/
|
|
14
|
+
export declare function disconnectRedis(): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Set a value with optional TTL (seconds)
|
|
17
|
+
*/
|
|
18
|
+
export declare function redisSet<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Get a typed value
|
|
21
|
+
*/
|
|
22
|
+
export declare function redisGet<T>(key: string): Promise<T | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Delete a key
|
|
25
|
+
* @returns number of keys deleted (0 or 1)
|
|
26
|
+
*/
|
|
27
|
+
export declare function redisDel(key: string): Promise<number>;
|
|
28
|
+
/**
|
|
29
|
+
* Check if a key exists
|
|
30
|
+
*/
|
|
31
|
+
export declare function redisExists(key: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Get keys matching a pattern
|
|
34
|
+
*/
|
|
35
|
+
export declare function redisKeys(pattern: string): Promise<string[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Push value to the right of a list (RPUSH)
|
|
38
|
+
* @returns length of list after push
|
|
39
|
+
*/
|
|
40
|
+
export declare function redisRPush(key: string, value: string): Promise<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Pop value from the left of a list (LPOP)
|
|
43
|
+
* @returns the popped value or null if list is empty
|
|
44
|
+
*/
|
|
45
|
+
export declare function redisLPop(key: string): Promise<string | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Get a range of elements from a list (LRANGE)
|
|
48
|
+
* @param start - Start index (0-based, inclusive)
|
|
49
|
+
* @param stop - Stop index (inclusive, -1 for end)
|
|
50
|
+
*/
|
|
51
|
+
export declare function redisLRange(key: string, start: number, stop: number): Promise<string[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Get the length of a list (LLEN)
|
|
54
|
+
*/
|
|
55
|
+
export declare function redisLLen(key: string): Promise<number>;
|
|
56
|
+
/**
|
|
57
|
+
* Remove elements from a list (LREM)
|
|
58
|
+
* @param count - Number of occurrences to remove (0 = all)
|
|
59
|
+
* @returns number of elements removed
|
|
60
|
+
*/
|
|
61
|
+
export declare function redisLRem(key: string, count: number, value: string): Promise<number>;
|
|
62
|
+
/**
|
|
63
|
+
* Add member to a set (SADD)
|
|
64
|
+
* @returns number of elements added (0 if already exists)
|
|
65
|
+
*/
|
|
66
|
+
export declare function redisSAdd(key: string, member: string): Promise<number>;
|
|
67
|
+
/**
|
|
68
|
+
* Remove member from a set (SREM)
|
|
69
|
+
* @returns number of elements removed
|
|
70
|
+
*/
|
|
71
|
+
export declare function redisSRem(key: string, member: string): Promise<number>;
|
|
72
|
+
/**
|
|
73
|
+
* Get all members of a set (SMEMBERS)
|
|
74
|
+
*/
|
|
75
|
+
export declare function redisSMembers(key: string): Promise<string[]>;
|
|
76
|
+
/**
|
|
77
|
+
* Get the number of members in a set (SCARD)
|
|
78
|
+
*/
|
|
79
|
+
export declare function redisSCard(key: string): Promise<number>;
|
|
80
|
+
/**
|
|
81
|
+
* Set a value only if key does not exist (SETNX)
|
|
82
|
+
* @returns true if key was set, false if it already existed
|
|
83
|
+
*/
|
|
84
|
+
export declare function redisSetNX(key: string, value: string, ttlSeconds?: number): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Set TTL on an existing key (EXPIRE)
|
|
87
|
+
* @returns true if TTL was set, false if key doesn't exist
|
|
88
|
+
*/
|
|
89
|
+
export declare function redisExpire(key: string, ttlSeconds: number): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Add member to a sorted set with score (ZADD)
|
|
92
|
+
* @returns number of elements added (0 if already exists, updates score)
|
|
93
|
+
*/
|
|
94
|
+
export declare function redisZAdd(key: string, score: number, member: string): Promise<number>;
|
|
95
|
+
/**
|
|
96
|
+
* Remove member from a sorted set (ZREM)
|
|
97
|
+
* @returns number of elements removed
|
|
98
|
+
*/
|
|
99
|
+
export declare function redisZRem(key: string, member: string): Promise<number>;
|
|
100
|
+
/**
|
|
101
|
+
* Get members from sorted set by score range (ZRANGEBYSCORE)
|
|
102
|
+
* Returns members with lowest scores first (highest priority)
|
|
103
|
+
* @param min - Minimum score (use '-inf' for no minimum)
|
|
104
|
+
* @param max - Maximum score (use '+inf' for no maximum)
|
|
105
|
+
* @param limit - Maximum number of results
|
|
106
|
+
*/
|
|
107
|
+
export declare function redisZRangeByScore(key: string, min: number | string, max: number | string, limit?: number): Promise<string[]>;
|
|
108
|
+
/**
|
|
109
|
+
* Get the number of members in a sorted set (ZCARD)
|
|
110
|
+
*/
|
|
111
|
+
export declare function redisZCard(key: string): Promise<number>;
|
|
112
|
+
/**
|
|
113
|
+
* Pop the member with the lowest score (ZPOPMIN)
|
|
114
|
+
* @returns [member, score] or null if set is empty
|
|
115
|
+
*/
|
|
116
|
+
export declare function redisZPopMin(key: string): Promise<{
|
|
117
|
+
member: string;
|
|
118
|
+
score: number;
|
|
119
|
+
} | null>;
|
|
120
|
+
/**
|
|
121
|
+
* Set a field in a hash (HSET)
|
|
122
|
+
* @returns 1 if field is new, 0 if field existed
|
|
123
|
+
*/
|
|
124
|
+
export declare function redisHSet(key: string, field: string, value: string): Promise<number>;
|
|
125
|
+
/**
|
|
126
|
+
* Get a field from a hash (HGET)
|
|
127
|
+
*/
|
|
128
|
+
export declare function redisHGet(key: string, field: string): Promise<string | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Delete a field from a hash (HDEL)
|
|
131
|
+
* @returns number of fields removed
|
|
132
|
+
*/
|
|
133
|
+
export declare function redisHDel(key: string, field: string): Promise<number>;
|
|
134
|
+
/**
|
|
135
|
+
* Get multiple fields from a hash (HMGET)
|
|
136
|
+
*/
|
|
137
|
+
export declare function redisHMGet(key: string, fields: string[]): Promise<(string | null)[]>;
|
|
138
|
+
/**
|
|
139
|
+
* Get all fields and values from a hash (HGETALL)
|
|
140
|
+
*/
|
|
141
|
+
export declare function redisHGetAll(key: string): Promise<Record<string, string>>;
|
|
142
|
+
/**
|
|
143
|
+
* Get the number of fields in a hash (HLEN)
|
|
144
|
+
*/
|
|
145
|
+
export declare function redisHLen(key: string): Promise<number>;
|
|
146
|
+
//# sourceMappingURL=redis.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/redis.ts"],"names":[],"mappings":"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"}
|