@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dist/src/a2a-server.d.ts +88 -0
  4. package/dist/src/a2a-server.d.ts.map +1 -0
  5. package/dist/src/a2a-server.integration.test.d.ts +9 -0
  6. package/dist/src/a2a-server.integration.test.d.ts.map +1 -0
  7. package/dist/src/a2a-server.integration.test.js +397 -0
  8. package/dist/src/a2a-server.js +235 -0
  9. package/dist/src/a2a-server.test.d.ts +2 -0
  10. package/dist/src/a2a-server.test.d.ts.map +1 -0
  11. package/dist/src/a2a-server.test.js +311 -0
  12. package/dist/src/a2a-types.d.ts +125 -0
  13. package/dist/src/a2a-types.d.ts.map +1 -0
  14. package/dist/src/a2a-types.js +8 -0
  15. package/dist/src/agent-tracking.d.ts +201 -0
  16. package/dist/src/agent-tracking.d.ts.map +1 -0
  17. package/dist/src/agent-tracking.js +349 -0
  18. package/dist/src/env-validation.d.ts +65 -0
  19. package/dist/src/env-validation.d.ts.map +1 -0
  20. package/dist/src/env-validation.js +134 -0
  21. package/dist/src/governor-dedup.d.ts +15 -0
  22. package/dist/src/governor-dedup.d.ts.map +1 -0
  23. package/dist/src/governor-dedup.js +31 -0
  24. package/dist/src/governor-event-bus.d.ts +54 -0
  25. package/dist/src/governor-event-bus.d.ts.map +1 -0
  26. package/dist/src/governor-event-bus.js +152 -0
  27. package/dist/src/governor-storage.d.ts +28 -0
  28. package/dist/src/governor-storage.d.ts.map +1 -0
  29. package/dist/src/governor-storage.js +52 -0
  30. package/dist/src/index.d.ts +26 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +50 -0
  33. package/dist/src/issue-lock.d.ts +129 -0
  34. package/dist/src/issue-lock.d.ts.map +1 -0
  35. package/dist/src/issue-lock.js +508 -0
  36. package/dist/src/logger.d.ts +76 -0
  37. package/dist/src/logger.d.ts.map +1 -0
  38. package/dist/src/logger.js +218 -0
  39. package/dist/src/orphan-cleanup.d.ts +64 -0
  40. package/dist/src/orphan-cleanup.d.ts.map +1 -0
  41. package/dist/src/orphan-cleanup.js +369 -0
  42. package/dist/src/pending-prompts.d.ts +67 -0
  43. package/dist/src/pending-prompts.d.ts.map +1 -0
  44. package/dist/src/pending-prompts.js +176 -0
  45. package/dist/src/processing-state-storage.d.ts +38 -0
  46. package/dist/src/processing-state-storage.d.ts.map +1 -0
  47. package/dist/src/processing-state-storage.js +61 -0
  48. package/dist/src/quota-tracker.d.ts +62 -0
  49. package/dist/src/quota-tracker.d.ts.map +1 -0
  50. package/dist/src/quota-tracker.js +155 -0
  51. package/dist/src/rate-limit.d.ts +111 -0
  52. package/dist/src/rate-limit.d.ts.map +1 -0
  53. package/dist/src/rate-limit.js +171 -0
  54. package/dist/src/redis-circuit-breaker.d.ts +67 -0
  55. package/dist/src/redis-circuit-breaker.d.ts.map +1 -0
  56. package/dist/src/redis-circuit-breaker.js +290 -0
  57. package/dist/src/redis-rate-limiter.d.ts +51 -0
  58. package/dist/src/redis-rate-limiter.d.ts.map +1 -0
  59. package/dist/src/redis-rate-limiter.js +168 -0
  60. package/dist/src/redis.d.ts +146 -0
  61. package/dist/src/redis.d.ts.map +1 -0
  62. package/dist/src/redis.js +343 -0
  63. package/dist/src/session-hash.d.ts +48 -0
  64. package/dist/src/session-hash.d.ts.map +1 -0
  65. package/dist/src/session-hash.js +80 -0
  66. package/dist/src/session-storage.d.ts +166 -0
  67. package/dist/src/session-storage.d.ts.map +1 -0
  68. package/dist/src/session-storage.js +397 -0
  69. package/dist/src/token-storage.d.ts +118 -0
  70. package/dist/src/token-storage.d.ts.map +1 -0
  71. package/dist/src/token-storage.js +263 -0
  72. package/dist/src/types.d.ts +11 -0
  73. package/dist/src/types.d.ts.map +1 -0
  74. package/dist/src/types.js +7 -0
  75. package/dist/src/webhook-idempotency.d.ts +44 -0
  76. package/dist/src/webhook-idempotency.d.ts.map +1 -0
  77. package/dist/src/webhook-idempotency.js +148 -0
  78. package/dist/src/work-queue.d.ts +120 -0
  79. package/dist/src/work-queue.d.ts.map +1 -0
  80. package/dist/src/work-queue.js +384 -0
  81. package/dist/src/worker-auth.d.ts +29 -0
  82. package/dist/src/worker-auth.d.ts.map +1 -0
  83. package/dist/src/worker-auth.js +49 -0
  84. package/dist/src/worker-storage.d.ts +108 -0
  85. package/dist/src/worker-storage.d.ts.map +1 -0
  86. package/dist/src/worker-storage.js +295 -0
  87. package/dist/src/workflow-state-integration.test.d.ts +2 -0
  88. package/dist/src/workflow-state-integration.test.d.ts.map +1 -0
  89. package/dist/src/workflow-state-integration.test.js +342 -0
  90. package/dist/src/workflow-state.test.d.ts +2 -0
  91. package/dist/src/workflow-state.test.d.ts.map +1 -0
  92. package/dist/src/workflow-state.test.js +113 -0
  93. 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":"AAAA,OAAO,KAAuB,MAAM,SAAS,CAAA;AA6D7C;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,KAAK,CA0BtC;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAMrD;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAC9B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,CAAC,EACR,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAShE;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG3D;AAED;;GAEG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI/D;AAED;;GAEG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAGlE;AAMD;;;GAGG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5E;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAGnE;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,EAAE,CAAC,CAGnB;AAED;;GAEG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC,CAGjB;AAMD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5E;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5E;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAGlE;AAED;;GAEG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7D;AAMD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,CAIlB;AAMD;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5E;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,EAAE,CAAC,CAMnB;AAED;;GAEG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7D;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOnD;AAMD;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAGxB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG3E;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EAAE,GACf,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAI5B;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAGjC;AAED;;GAEG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}