@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,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"}
|