@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,61 @@
1
+ /**
2
+ * Redis-backed Processing State Storage
3
+ *
4
+ * Implements the `ProcessingStateStorage` interface from `@renseiai/agentfactory`
5
+ * using Redis for persistence. Used by the top-of-funnel governor to track
6
+ * which processing phases (research, backlog-creation) have been completed
7
+ * for each issue.
8
+ *
9
+ * Key format: `governor:processing:{issueId}:{phase}`
10
+ * TTL: 30 days (matches workflow state TTL)
11
+ */
12
+ import { redisSet, redisGet, redisDel, redisExists } from './redis.js';
13
+ // Redis key prefix for processing state records
14
+ const PROCESSING_STATE_PREFIX = 'governor:processing:';
15
+ // 30-day TTL in seconds
16
+ const PROCESSING_STATE_TTL = 30 * 24 * 60 * 60;
17
+ /**
18
+ * Build the Redis key for a specific issue + phase combination.
19
+ */
20
+ function redisKey(issueId, phase) {
21
+ return `${PROCESSING_STATE_PREFIX}${issueId}:${phase}`;
22
+ }
23
+ /**
24
+ * Redis-backed implementation of `ProcessingStateStorage`.
25
+ *
26
+ * Each phase completion is stored as an independent key so that phases
27
+ * can be checked and cleared independently without affecting each other.
28
+ */
29
+ export class RedisProcessingStateStorage {
30
+ /**
31
+ * Check whether a given phase has already been completed for an issue.
32
+ */
33
+ async isPhaseCompleted(issueId, phase) {
34
+ return redisExists(redisKey(issueId, phase));
35
+ }
36
+ /**
37
+ * Mark a phase as completed for an issue.
38
+ * Stores a `ProcessingRecord` JSON object with a 30-day TTL.
39
+ */
40
+ async markPhaseCompleted(issueId, phase, sessionId) {
41
+ const record = {
42
+ issueId,
43
+ phase,
44
+ completedAt: Date.now(),
45
+ sessionId,
46
+ };
47
+ await redisSet(redisKey(issueId, phase), record, PROCESSING_STATE_TTL);
48
+ }
49
+ /**
50
+ * Clear a phase completion record for an issue.
51
+ */
52
+ async clearPhase(issueId, phase) {
53
+ await redisDel(redisKey(issueId, phase));
54
+ }
55
+ /**
56
+ * Retrieve the processing record for a phase, if it exists.
57
+ */
58
+ async getPhaseRecord(issueId, phase) {
59
+ return redisGet(redisKey(issueId, phase));
60
+ }
61
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Linear API Quota Tracker
3
+ *
4
+ * Stores Linear's rate limit response headers in Redis so any component
5
+ * can check the remaining budget before making a call.
6
+ *
7
+ * Redis keys:
8
+ * - `linear:quota:{workspaceId}:requests_remaining`
9
+ * - `linear:quota:{workspaceId}:complexity_remaining`
10
+ * - `linear:quota:{workspaceId}:requests_reset` (timestamp)
11
+ * - `linear:quota:{workspaceId}:updated_at` (timestamp)
12
+ *
13
+ * Usage:
14
+ * - After every Linear API call, call `recordQuota()` with response headers
15
+ * - Before making a call, check `getQuota()` to see remaining budget
16
+ * - If `requestsRemaining < LOW_QUOTA_THRESHOLD`, the rate limiter
17
+ * should proactively throttle
18
+ */
19
+ /** Threshold below which we should proactively throttle */
20
+ export declare const LOW_QUOTA_THRESHOLD = 500;
21
+ export interface LinearQuotaSnapshot {
22
+ /** Remaining request quota (from X-RateLimit-Requests-Remaining) */
23
+ requestsRemaining: number | null;
24
+ /** Remaining complexity quota (from X-RateLimit-Complexity-Remaining) */
25
+ complexityRemaining: number | null;
26
+ /** Timestamp when request quota resets (from X-RateLimit-Requests-Reset) */
27
+ requestsReset: number | null;
28
+ /** When this snapshot was last updated */
29
+ updatedAt: number;
30
+ }
31
+ /**
32
+ * Record quota information from Linear API response headers.
33
+ *
34
+ * Call this after every successful Linear API response.
35
+ */
36
+ export declare function recordQuota(workspaceId: string, headers: {
37
+ requestsRemaining?: string | number | null;
38
+ complexityRemaining?: string | number | null;
39
+ requestsReset?: string | number | null;
40
+ }): Promise<void>;
41
+ /**
42
+ * Get the current quota snapshot for a workspace.
43
+ */
44
+ export declare function getQuota(workspaceId: string): Promise<LinearQuotaSnapshot>;
45
+ /**
46
+ * Check if quota is critically low for a workspace.
47
+ *
48
+ * Returns true if we know the quota is below the threshold.
49
+ * Returns false if quota is healthy or unknown (fail open).
50
+ */
51
+ export declare function isQuotaLow(workspaceId: string): Promise<boolean>;
52
+ /**
53
+ * Extract quota headers from a Linear API response.
54
+ *
55
+ * Works with both fetch Response objects and plain header objects.
56
+ */
57
+ export declare function extractQuotaHeaders(response: unknown): {
58
+ requestsRemaining?: string;
59
+ complexityRemaining?: string;
60
+ requestsReset?: string;
61
+ };
62
+ //# sourceMappingURL=quota-tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quota-tracker.d.ts","sourceRoot":"","sources":["../../src/quota-tracker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAOH,2DAA2D;AAC3D,eAAO,MAAM,mBAAmB,MAAM,CAAA;AAKtC,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,yEAAyE;IACzE,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,4EAA4E;IAC5E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IACP,iBAAiB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC1C,mBAAmB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC5C,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CACvC,GACA,OAAO,CAAC,IAAI,CAAC,CAgDf;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiChF;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUtE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,GAAG;IACtD,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAkCA"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Linear API Quota Tracker
3
+ *
4
+ * Stores Linear's rate limit response headers in Redis so any component
5
+ * can check the remaining budget before making a call.
6
+ *
7
+ * Redis keys:
8
+ * - `linear:quota:{workspaceId}:requests_remaining`
9
+ * - `linear:quota:{workspaceId}:complexity_remaining`
10
+ * - `linear:quota:{workspaceId}:requests_reset` (timestamp)
11
+ * - `linear:quota:{workspaceId}:updated_at` (timestamp)
12
+ *
13
+ * Usage:
14
+ * - After every Linear API call, call `recordQuota()` with response headers
15
+ * - Before making a call, check `getQuota()` to see remaining budget
16
+ * - If `requestsRemaining < LOW_QUOTA_THRESHOLD`, the rate limiter
17
+ * should proactively throttle
18
+ */
19
+ import { getRedisClient } from './redis.js';
20
+ import { createLogger } from './logger.js';
21
+ const log = createLogger('quota-tracker');
22
+ /** Threshold below which we should proactively throttle */
23
+ export const LOW_QUOTA_THRESHOLD = 500;
24
+ /** Default quota TTL in Redis (2 hours, matching Linear's hourly reset) */
25
+ const QUOTA_TTL_SECONDS = 7200;
26
+ /**
27
+ * Record quota information from Linear API response headers.
28
+ *
29
+ * Call this after every successful Linear API response.
30
+ */
31
+ export async function recordQuota(workspaceId, headers) {
32
+ try {
33
+ const redis = getRedisClient();
34
+ const prefix = `linear:quota:${workspaceId}`;
35
+ const pipeline = redis.pipeline();
36
+ const now = Date.now();
37
+ if (headers.requestsRemaining != null) {
38
+ const value = String(headers.requestsRemaining);
39
+ pipeline.set(`${prefix}:requests_remaining`, value, 'EX', QUOTA_TTL_SECONDS);
40
+ const remaining = parseInt(value, 10);
41
+ if (!isNaN(remaining) && remaining < LOW_QUOTA_THRESHOLD) {
42
+ log.warn('Linear quota running low', {
43
+ workspaceId,
44
+ requestsRemaining: remaining,
45
+ });
46
+ }
47
+ }
48
+ if (headers.complexityRemaining != null) {
49
+ pipeline.set(`${prefix}:complexity_remaining`, String(headers.complexityRemaining), 'EX', QUOTA_TTL_SECONDS);
50
+ }
51
+ if (headers.requestsReset != null) {
52
+ pipeline.set(`${prefix}:requests_reset`, String(headers.requestsReset), 'EX', QUOTA_TTL_SECONDS);
53
+ }
54
+ pipeline.set(`${prefix}:updated_at`, String(now), 'EX', QUOTA_TTL_SECONDS);
55
+ await pipeline.exec();
56
+ }
57
+ catch (err) {
58
+ // Non-critical — log and continue
59
+ log.error('Failed to record quota', {
60
+ workspaceId,
61
+ error: err instanceof Error ? err.message : String(err),
62
+ });
63
+ }
64
+ }
65
+ /**
66
+ * Get the current quota snapshot for a workspace.
67
+ */
68
+ export async function getQuota(workspaceId) {
69
+ try {
70
+ const redis = getRedisClient();
71
+ const prefix = `linear:quota:${workspaceId}`;
72
+ const [requestsRemaining, complexityRemaining, requestsReset, updatedAt] = await Promise.all([
73
+ redis.get(`${prefix}:requests_remaining`),
74
+ redis.get(`${prefix}:complexity_remaining`),
75
+ redis.get(`${prefix}:requests_reset`),
76
+ redis.get(`${prefix}:updated_at`),
77
+ ]);
78
+ return {
79
+ requestsRemaining: requestsRemaining ? parseInt(requestsRemaining, 10) : null,
80
+ complexityRemaining: complexityRemaining
81
+ ? parseInt(complexityRemaining, 10)
82
+ : null,
83
+ requestsReset: requestsReset ? parseInt(requestsReset, 10) : null,
84
+ updatedAt: updatedAt ? parseInt(updatedAt, 10) : 0,
85
+ };
86
+ }
87
+ catch (err) {
88
+ log.error('Failed to get quota', {
89
+ workspaceId,
90
+ error: err instanceof Error ? err.message : String(err),
91
+ });
92
+ return {
93
+ requestsRemaining: null,
94
+ complexityRemaining: null,
95
+ requestsReset: null,
96
+ updatedAt: 0,
97
+ };
98
+ }
99
+ }
100
+ /**
101
+ * Check if quota is critically low for a workspace.
102
+ *
103
+ * Returns true if we know the quota is below the threshold.
104
+ * Returns false if quota is healthy or unknown (fail open).
105
+ */
106
+ export async function isQuotaLow(workspaceId) {
107
+ const quota = await getQuota(workspaceId);
108
+ if (quota.requestsRemaining === null)
109
+ return false; // unknown = allow
110
+ // Check staleness — if data is older than 5 minutes, don't trust it
111
+ const staleThreshold = 5 * 60 * 1000;
112
+ if (Date.now() - quota.updatedAt > staleThreshold)
113
+ return false;
114
+ return quota.requestsRemaining < LOW_QUOTA_THRESHOLD;
115
+ }
116
+ /**
117
+ * Extract quota headers from a Linear API response.
118
+ *
119
+ * Works with both fetch Response objects and plain header objects.
120
+ */
121
+ export function extractQuotaHeaders(response) {
122
+ const result = {};
123
+ if (typeof response !== 'object' || response === null)
124
+ return result;
125
+ const resp = response;
126
+ // Try fetch-style Response with .headers.get()
127
+ const headers = resp.headers;
128
+ if (headers) {
129
+ if (typeof headers.get === 'function') {
130
+ const getHeader = headers.get;
131
+ const rr = getHeader.call(headers, 'x-ratelimit-requests-remaining');
132
+ const cr = getHeader.call(headers, 'x-ratelimit-complexity-remaining');
133
+ const rs = getHeader.call(headers, 'x-ratelimit-requests-reset');
134
+ if (rr)
135
+ result.requestsRemaining = rr;
136
+ if (cr)
137
+ result.complexityRemaining = cr;
138
+ if (rs)
139
+ result.requestsReset = rs;
140
+ }
141
+ else {
142
+ // Plain object headers
143
+ const rr = headers['x-ratelimit-requests-remaining'];
144
+ const cr = headers['x-ratelimit-complexity-remaining'];
145
+ const rs = headers['x-ratelimit-requests-reset'];
146
+ if (rr)
147
+ result.requestsRemaining = rr;
148
+ if (cr)
149
+ result.complexityRemaining = cr;
150
+ if (rs)
151
+ result.requestsReset = rs;
152
+ }
153
+ }
154
+ return result;
155
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Rate Limiter with LRU Cache
3
+ *
4
+ * In-memory rate limiting using sliding window algorithm.
5
+ * Uses LRU cache to prevent memory bloat from tracking many IPs.
6
+ */
7
+ /**
8
+ * Rate limit configuration
9
+ */
10
+ export interface RateLimitConfig {
11
+ /** Maximum requests allowed in the window */
12
+ limit: number;
13
+ /** Window size in milliseconds */
14
+ windowMs: number;
15
+ }
16
+ /**
17
+ * Rate limit check result
18
+ */
19
+ export interface RateLimitResult {
20
+ /** Whether the request is allowed */
21
+ allowed: boolean;
22
+ /** Remaining requests in current window */
23
+ remaining: number;
24
+ /** Time until window resets (milliseconds) */
25
+ resetIn: number;
26
+ /** Total limit for this endpoint */
27
+ limit: number;
28
+ }
29
+ /**
30
+ * Default rate limit configurations by endpoint type
31
+ */
32
+ export declare const RATE_LIMITS: {
33
+ /** Public API endpoints - 60 requests per minute */
34
+ readonly public: {
35
+ readonly limit: 60;
36
+ readonly windowMs: number;
37
+ };
38
+ /** Webhook endpoint - 10 requests per second per IP */
39
+ readonly webhook: {
40
+ readonly limit: 10;
41
+ readonly windowMs: 1000;
42
+ };
43
+ /** Dashboard - 30 requests per minute */
44
+ readonly dashboard: {
45
+ readonly limit: 30;
46
+ readonly windowMs: number;
47
+ };
48
+ };
49
+ /**
50
+ * LRU Rate Limiter
51
+ *
52
+ * Tracks request counts per key (typically IP address) using
53
+ * sliding window algorithm. Old entries are evicted using LRU policy.
54
+ */
55
+ export declare class RateLimiter {
56
+ private cache;
57
+ private maxEntries;
58
+ private config;
59
+ constructor(config: RateLimitConfig, maxEntries?: number);
60
+ /**
61
+ * Check if a request should be allowed
62
+ *
63
+ * @param key - Unique identifier (usually IP address)
64
+ * @returns Rate limit result
65
+ */
66
+ check(key: string): RateLimitResult;
67
+ /**
68
+ * Evict least recently used entries if cache is full
69
+ */
70
+ private evictIfNeeded;
71
+ /**
72
+ * Clear all entries (useful for testing)
73
+ */
74
+ clear(): void;
75
+ /**
76
+ * Get current cache size
77
+ */
78
+ get size(): number;
79
+ }
80
+ /**
81
+ * Get or create a rate limiter for an endpoint type
82
+ *
83
+ * @param type - Endpoint type ('public', 'webhook', 'dashboard')
84
+ * @returns Rate limiter instance
85
+ */
86
+ export declare function getRateLimiter(type: keyof typeof RATE_LIMITS): RateLimiter;
87
+ /**
88
+ * Check rate limit for a request
89
+ *
90
+ * @param type - Endpoint type
91
+ * @param key - Unique identifier (usually IP)
92
+ * @returns Rate limit result
93
+ */
94
+ export declare function checkRateLimit(type: keyof typeof RATE_LIMITS, key: string): RateLimitResult;
95
+ /**
96
+ * Extract client IP from request headers
97
+ *
98
+ * Handles various proxy scenarios (Vercel, Cloudflare, etc.)
99
+ *
100
+ * @param headers - Request headers
101
+ * @returns Client IP address
102
+ */
103
+ export declare function getClientIP(headers: Headers): string;
104
+ /**
105
+ * Build rate limit headers for response
106
+ *
107
+ * @param result - Rate limit result
108
+ * @returns Headers object
109
+ */
110
+ export declare function buildRateLimitHeaders(result: RateLimitResult): Record<string, string>;
111
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAA;IACb,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,qCAAqC;IACrC,OAAO,EAAE,OAAO,CAAA;IAChB,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAA;IACjB,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAA;CACd;AAYD;;GAEG;AACH,eAAO,MAAM,WAAW;IACtB,oDAAoD;;;;;IAEpD,uDAAuD;;;;;IAEvD,yCAAyC;;;;;CAEjC,CAAA;AAEV;;;;;GAKG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAyC;IACtD,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,MAAM,CAAiB;gBAEnB,MAAM,EAAE,eAAe,EAAE,UAAU,SAAQ;IAKvD;;;;;OAKG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe;IA4CnC;;OAEG;IACH,OAAO,CAAC,aAAa;IAgBrB;;OAEG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAKD;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,OAAO,WAAW,GAC7B,WAAW,CAOb;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,OAAO,WAAW,EAC9B,GAAG,EAAE,MAAM,GACV,eAAe,CAGjB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAsBpD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,eAAe,GACtB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMxB"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Rate Limiter with LRU Cache
3
+ *
4
+ * In-memory rate limiting using sliding window algorithm.
5
+ * Uses LRU cache to prevent memory bloat from tracking many IPs.
6
+ */
7
+ import { createLogger } from './logger.js';
8
+ const log = createLogger('rate-limit');
9
+ /**
10
+ * Default rate limit configurations by endpoint type
11
+ */
12
+ export const RATE_LIMITS = {
13
+ /** Public API endpoints - 60 requests per minute */
14
+ public: { limit: 60, windowMs: 60 * 1000 },
15
+ /** Webhook endpoint - 10 requests per second per IP */
16
+ webhook: { limit: 10, windowMs: 1000 },
17
+ /** Dashboard - 30 requests per minute */
18
+ dashboard: { limit: 30, windowMs: 60 * 1000 },
19
+ };
20
+ /**
21
+ * LRU Rate Limiter
22
+ *
23
+ * Tracks request counts per key (typically IP address) using
24
+ * sliding window algorithm. Old entries are evicted using LRU policy.
25
+ */
26
+ export class RateLimiter {
27
+ cache = new Map();
28
+ maxEntries;
29
+ config;
30
+ constructor(config, maxEntries = 10000) {
31
+ this.config = config;
32
+ this.maxEntries = maxEntries;
33
+ }
34
+ /**
35
+ * Check if a request should be allowed
36
+ *
37
+ * @param key - Unique identifier (usually IP address)
38
+ * @returns Rate limit result
39
+ */
40
+ check(key) {
41
+ const now = Date.now();
42
+ const windowStart = now - this.config.windowMs;
43
+ // Get or create entry
44
+ let entry = this.cache.get(key);
45
+ if (!entry) {
46
+ entry = { timestamps: [], lastAccess: now };
47
+ }
48
+ // Filter timestamps to only include those in the current window
49
+ entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart);
50
+ entry.lastAccess = now;
51
+ // Calculate remaining requests
52
+ const requestCount = entry.timestamps.length;
53
+ const remaining = Math.max(0, this.config.limit - requestCount);
54
+ const allowed = requestCount < this.config.limit;
55
+ // Add this request if allowed
56
+ if (allowed) {
57
+ entry.timestamps.push(now);
58
+ }
59
+ // Update cache
60
+ this.cache.set(key, entry);
61
+ // Evict old entries if needed
62
+ this.evictIfNeeded();
63
+ // Calculate reset time
64
+ const oldestTimestamp = entry.timestamps[0];
65
+ const resetIn = oldestTimestamp
66
+ ? Math.max(0, oldestTimestamp + this.config.windowMs - now)
67
+ : 0;
68
+ return {
69
+ allowed,
70
+ remaining: allowed ? remaining - 1 : 0,
71
+ resetIn,
72
+ limit: this.config.limit,
73
+ };
74
+ }
75
+ /**
76
+ * Evict least recently used entries if cache is full
77
+ */
78
+ evictIfNeeded() {
79
+ if (this.cache.size <= this.maxEntries)
80
+ return;
81
+ // Find entries to evict (oldest lastAccess)
82
+ const entries = Array.from(this.cache.entries());
83
+ entries.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
84
+ // Remove oldest 10% of entries
85
+ const toRemove = Math.ceil(this.maxEntries * 0.1);
86
+ for (let i = 0; i < toRemove && i < entries.length; i++) {
87
+ this.cache.delete(entries[i][0]);
88
+ }
89
+ log.debug('Evicted rate limit entries', { removed: toRemove });
90
+ }
91
+ /**
92
+ * Clear all entries (useful for testing)
93
+ */
94
+ clear() {
95
+ this.cache.clear();
96
+ }
97
+ /**
98
+ * Get current cache size
99
+ */
100
+ get size() {
101
+ return this.cache.size;
102
+ }
103
+ }
104
+ // Singleton rate limiters for different endpoint types
105
+ const limiters = new Map();
106
+ /**
107
+ * Get or create a rate limiter for an endpoint type
108
+ *
109
+ * @param type - Endpoint type ('public', 'webhook', 'dashboard')
110
+ * @returns Rate limiter instance
111
+ */
112
+ export function getRateLimiter(type) {
113
+ let limiter = limiters.get(type);
114
+ if (!limiter) {
115
+ limiter = new RateLimiter(RATE_LIMITS[type]);
116
+ limiters.set(type, limiter);
117
+ }
118
+ return limiter;
119
+ }
120
+ /**
121
+ * Check rate limit for a request
122
+ *
123
+ * @param type - Endpoint type
124
+ * @param key - Unique identifier (usually IP)
125
+ * @returns Rate limit result
126
+ */
127
+ export function checkRateLimit(type, key) {
128
+ const limiter = getRateLimiter(type);
129
+ return limiter.check(key);
130
+ }
131
+ /**
132
+ * Extract client IP from request headers
133
+ *
134
+ * Handles various proxy scenarios (Vercel, Cloudflare, etc.)
135
+ *
136
+ * @param headers - Request headers
137
+ * @returns Client IP address
138
+ */
139
+ export function getClientIP(headers) {
140
+ // Vercel/Cloudflare proxy headers
141
+ const forwardedFor = headers.get('x-forwarded-for');
142
+ if (forwardedFor) {
143
+ // Take first IP (client IP before proxies)
144
+ return forwardedFor.split(',')[0].trim();
145
+ }
146
+ // Cloudflare specific
147
+ const cfConnectingIP = headers.get('cf-connecting-ip');
148
+ if (cfConnectingIP) {
149
+ return cfConnectingIP;
150
+ }
151
+ // Vercel specific
152
+ const realIP = headers.get('x-real-ip');
153
+ if (realIP) {
154
+ return realIP;
155
+ }
156
+ // Fallback
157
+ return 'unknown';
158
+ }
159
+ /**
160
+ * Build rate limit headers for response
161
+ *
162
+ * @param result - Rate limit result
163
+ * @returns Headers object
164
+ */
165
+ export function buildRateLimitHeaders(result) {
166
+ return {
167
+ 'X-RateLimit-Limit': result.limit.toString(),
168
+ 'X-RateLimit-Remaining': result.remaining.toString(),
169
+ 'X-RateLimit-Reset': Math.ceil(result.resetIn / 1000).toString(),
170
+ };
171
+ }
@@ -0,0 +1,67 @@
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 type { CircuitBreakerStrategy, CircuitBreakerConfig } from '@renseiai/agentfactory-linear';
18
+ export interface RedisCircuitBreakerConfig extends CircuitBreakerConfig {
19
+ /** Workspace-specific key prefix */
20
+ workspaceId: string;
21
+ }
22
+ export declare class RedisCircuitBreaker implements CircuitBreakerStrategy {
23
+ private readonly config;
24
+ private readonly keyPrefix;
25
+ constructor(config: Partial<RedisCircuitBreakerConfig> & {
26
+ workspaceId: string;
27
+ });
28
+ private get stateKey();
29
+ private get failuresKey();
30
+ private get openedAtKey();
31
+ private get resetTimeoutKey();
32
+ /**
33
+ * Check if a call is allowed to proceed.
34
+ */
35
+ canProceed(): Promise<boolean>;
36
+ /**
37
+ * Record a successful API call. Resets the circuit to closed.
38
+ */
39
+ recordSuccess(): Promise<void>;
40
+ /**
41
+ * Record an auth failure. May trip the circuit to open.
42
+ */
43
+ recordAuthFailure(_statusCode?: number): Promise<void>;
44
+ /**
45
+ * Check if an error is an auth/rate-limit error.
46
+ * Reuses the same detection logic as the in-memory CircuitBreaker.
47
+ */
48
+ isAuthError(error: unknown): boolean;
49
+ /**
50
+ * Reset the circuit breaker to closed state.
51
+ */
52
+ reset(): Promise<void>;
53
+ /**
54
+ * Get diagnostic info for monitoring.
55
+ */
56
+ getStatus(): Promise<{
57
+ state: string;
58
+ failures: number;
59
+ openedAt: number | null;
60
+ currentResetTimeoutMs: number;
61
+ }>;
62
+ }
63
+ /**
64
+ * Create a Redis circuit breaker for a specific workspace.
65
+ */
66
+ export declare function createRedisCircuitBreaker(workspaceId: string, config?: Partial<Omit<RedisCircuitBreakerConfig, 'workspaceId'>>): RedisCircuitBreaker;
67
+ //# sourceMappingURL=redis-circuit-breaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-circuit-breaker.d.ts","sourceRoot":"","sources":["../../src/redis-circuit-breaker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAA;AAIjG,MAAM,WAAW,yBAA0B,SAAQ,oBAAoB;IACrE,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAA;CACpB;AA6GD,qBAAa,mBAAoB,YAAW,sBAAsB;IAChE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAClD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;gBAEtB,MAAM,EAAE,OAAO,CAAC,yBAAyB,CAAC,GAAG;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE;IAKhF,OAAO,KAAK,QAAQ,GAEnB;IACD,OAAO,KAAK,WAAW,GAEtB;IACD,OAAO,KAAK,WAAW,GAEtB;IACD,OAAO,KAAK,eAAe,GAE1B;IAED;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAuBpC;;OAEG;IACG,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBpC;;OAEG;IACG,iBAAiB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B5D;;;OAGG;IACH,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;IAsCpC;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgB5B;;OAEG;IACG,SAAS,IAAI,OAAO,CAAC;QACzB,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;QACvB,qBAAqB,EAAE,MAAM,CAAA;KAC9B,CAAC;CA2BH;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,yBAAyB,EAAE,aAAa,CAAC,CAAC,GAC/D,mBAAmB,CAErB"}