@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,397 @@
|
|
|
1
|
+
import { isRedisConfigured, redisSet, redisGet, redisDel, redisKeys } from './redis.js';
|
|
2
|
+
const log = {
|
|
3
|
+
info: (msg, data) => console.log(`[session] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
4
|
+
warn: (msg, data) => console.warn(`[session] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
5
|
+
error: (msg, data) => console.error(`[session] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
6
|
+
debug: (_msg, _data) => { },
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Key prefix for session state in KV
|
|
10
|
+
*/
|
|
11
|
+
const SESSION_KEY_PREFIX = 'agent:session:';
|
|
12
|
+
/**
|
|
13
|
+
* Session state TTL in seconds (24 hours)
|
|
14
|
+
* Sessions older than this are automatically cleaned up by KV
|
|
15
|
+
*/
|
|
16
|
+
const SESSION_TTL_SECONDS = 24 * 60 * 60;
|
|
17
|
+
/**
|
|
18
|
+
* Build the KV key for a session
|
|
19
|
+
*/
|
|
20
|
+
function buildSessionKey(linearSessionId) {
|
|
21
|
+
return `${SESSION_KEY_PREFIX}${linearSessionId}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Store agent session state in Redis
|
|
25
|
+
*
|
|
26
|
+
* @param linearSessionId - The Linear session ID from webhook
|
|
27
|
+
* @param state - The session state to store
|
|
28
|
+
*/
|
|
29
|
+
export async function storeSessionState(linearSessionId, state) {
|
|
30
|
+
if (!isRedisConfigured()) {
|
|
31
|
+
log.warn('Redis not configured, session state will not be persisted');
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
return {
|
|
34
|
+
...state,
|
|
35
|
+
linearSessionId,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const now = Math.floor(Date.now() / 1000);
|
|
41
|
+
const key = buildSessionKey(linearSessionId);
|
|
42
|
+
// Check for existing session to preserve createdAt
|
|
43
|
+
const existing = await redisGet(key);
|
|
44
|
+
const sessionState = {
|
|
45
|
+
...state,
|
|
46
|
+
linearSessionId,
|
|
47
|
+
createdAt: existing?.createdAt ?? now,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
};
|
|
50
|
+
await redisSet(key, sessionState, SESSION_TTL_SECONDS);
|
|
51
|
+
log.info('Stored session state', {
|
|
52
|
+
linearSessionId,
|
|
53
|
+
issueId: state.issueId,
|
|
54
|
+
status: state.status,
|
|
55
|
+
hasProviderSessionId: !!state.providerSessionId,
|
|
56
|
+
});
|
|
57
|
+
return sessionState;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Retrieve agent session state from Redis
|
|
61
|
+
*
|
|
62
|
+
* @param linearSessionId - The Linear session ID
|
|
63
|
+
* @returns The session state or null if not found
|
|
64
|
+
*/
|
|
65
|
+
export async function getSessionState(linearSessionId) {
|
|
66
|
+
if (!isRedisConfigured()) {
|
|
67
|
+
log.debug('Redis not configured, cannot retrieve session state');
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const key = buildSessionKey(linearSessionId);
|
|
71
|
+
const state = await redisGet(key);
|
|
72
|
+
if (state) {
|
|
73
|
+
log.debug('Retrieved session state', {
|
|
74
|
+
linearSessionId,
|
|
75
|
+
issueId: state.issueId,
|
|
76
|
+
status: state.status,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Update the provider session ID for a session
|
|
83
|
+
* Called when the Claude init event is received with the session ID
|
|
84
|
+
*
|
|
85
|
+
* @param linearSessionId - The Linear session ID
|
|
86
|
+
* @param providerSessionId - The Provider CLI session ID
|
|
87
|
+
*/
|
|
88
|
+
export async function updateProviderSessionId(linearSessionId, providerSessionId) {
|
|
89
|
+
if (!isRedisConfigured()) {
|
|
90
|
+
log.warn('Redis not configured, cannot update provider session ID');
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const existing = await getSessionState(linearSessionId);
|
|
94
|
+
if (!existing) {
|
|
95
|
+
log.warn('Session not found for provider session ID update', { linearSessionId });
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const key = buildSessionKey(linearSessionId);
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
const updated = {
|
|
101
|
+
...existing,
|
|
102
|
+
providerSessionId,
|
|
103
|
+
updatedAt: now,
|
|
104
|
+
};
|
|
105
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
106
|
+
log.info('Updated provider session ID', { linearSessionId, providerSessionId });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Update session status
|
|
111
|
+
*
|
|
112
|
+
* @param linearSessionId - The Linear session ID
|
|
113
|
+
* @param status - The new status
|
|
114
|
+
*/
|
|
115
|
+
export async function updateSessionStatus(linearSessionId, status) {
|
|
116
|
+
if (!isRedisConfigured()) {
|
|
117
|
+
log.warn('Redis not configured, cannot update session status');
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const existing = await getSessionState(linearSessionId);
|
|
121
|
+
if (!existing) {
|
|
122
|
+
log.warn('Session not found for status update', { linearSessionId });
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const key = buildSessionKey(linearSessionId);
|
|
126
|
+
const now = Math.floor(Date.now() / 1000);
|
|
127
|
+
const updated = {
|
|
128
|
+
...existing,
|
|
129
|
+
status,
|
|
130
|
+
updatedAt: now,
|
|
131
|
+
};
|
|
132
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
133
|
+
log.info('Updated session status', { linearSessionId, status });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Update session cost data (tokens and USD)
|
|
138
|
+
*
|
|
139
|
+
* @param linearSessionId - The Linear session ID
|
|
140
|
+
* @param costData - Cost fields to persist
|
|
141
|
+
*/
|
|
142
|
+
export async function updateSessionCostData(linearSessionId, costData) {
|
|
143
|
+
if (!isRedisConfigured()) {
|
|
144
|
+
log.warn('Redis not configured, cannot update session cost data');
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const existing = await getSessionState(linearSessionId);
|
|
148
|
+
if (!existing) {
|
|
149
|
+
log.warn('Session not found for cost update', { linearSessionId });
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const key = buildSessionKey(linearSessionId);
|
|
153
|
+
const now = Math.floor(Date.now() / 1000);
|
|
154
|
+
const updated = {
|
|
155
|
+
...existing,
|
|
156
|
+
totalCostUsd: costData.totalCostUsd ?? existing.totalCostUsd,
|
|
157
|
+
inputTokens: costData.inputTokens ?? existing.inputTokens,
|
|
158
|
+
outputTokens: costData.outputTokens ?? existing.outputTokens,
|
|
159
|
+
updatedAt: now,
|
|
160
|
+
};
|
|
161
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
162
|
+
log.info('Updated session cost data', {
|
|
163
|
+
linearSessionId,
|
|
164
|
+
totalCostUsd: updated.totalCostUsd,
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Reset a session for re-queuing after orphan cleanup
|
|
170
|
+
* Clears workerId and resets status to pending so a new worker can claim it
|
|
171
|
+
*
|
|
172
|
+
* @param linearSessionId - The Linear session ID
|
|
173
|
+
*/
|
|
174
|
+
export async function resetSessionForRequeue(linearSessionId) {
|
|
175
|
+
if (!isRedisConfigured()) {
|
|
176
|
+
log.warn('Redis not configured, cannot reset session');
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const existing = await getSessionState(linearSessionId);
|
|
180
|
+
if (!existing) {
|
|
181
|
+
log.warn('Session not found for reset', { linearSessionId });
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const key = buildSessionKey(linearSessionId);
|
|
185
|
+
const now = Math.floor(Date.now() / 1000);
|
|
186
|
+
const updated = {
|
|
187
|
+
...existing,
|
|
188
|
+
status: 'pending',
|
|
189
|
+
workerId: undefined, // Clear workerId so new worker can claim
|
|
190
|
+
claimedAt: undefined,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
};
|
|
193
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
194
|
+
log.info('Reset session for requeue', {
|
|
195
|
+
linearSessionId,
|
|
196
|
+
previousWorkerId: existing.workerId,
|
|
197
|
+
});
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Delete session state from KV
|
|
202
|
+
*
|
|
203
|
+
* @param linearSessionId - The Linear session ID
|
|
204
|
+
* @returns Whether the deletion was successful
|
|
205
|
+
*/
|
|
206
|
+
export async function deleteSessionState(linearSessionId) {
|
|
207
|
+
if (!isRedisConfigured()) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const key = buildSessionKey(linearSessionId);
|
|
211
|
+
const result = await redisDel(key);
|
|
212
|
+
log.info('Deleted session state', { linearSessionId });
|
|
213
|
+
return result > 0;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get session state by issue ID
|
|
217
|
+
* Useful when we have the issue but not the session ID
|
|
218
|
+
*
|
|
219
|
+
* When multiple sessions exist for the same issue (e.g., one running + several
|
|
220
|
+
* failed), this function returns the most relevant one — preferring active
|
|
221
|
+
* sessions (running/claimed/pending) over inactive ones. Without this
|
|
222
|
+
* prioritization, an arbitrary first-match could hide a running session behind
|
|
223
|
+
* a failed one, causing the governor to re-dispatch work for an issue that
|
|
224
|
+
* already has an agent in-flight.
|
|
225
|
+
*
|
|
226
|
+
* @param issueId - The Linear issue ID
|
|
227
|
+
* @returns The most relevant session state for this issue or null
|
|
228
|
+
*/
|
|
229
|
+
export async function getSessionStateByIssue(issueId) {
|
|
230
|
+
if (!isRedisConfigured()) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
// Scan for sessions with this issue ID
|
|
234
|
+
// Note: This is less efficient than direct lookup, use sparingly
|
|
235
|
+
const keys = await redisKeys(`${SESSION_KEY_PREFIX}*`);
|
|
236
|
+
const activeStatuses = ['running', 'claimed', 'pending'];
|
|
237
|
+
let fallback = null;
|
|
238
|
+
for (const key of keys) {
|
|
239
|
+
const state = await redisGet(key);
|
|
240
|
+
if (state?.issueId === issueId) {
|
|
241
|
+
// Prefer active sessions — return immediately if found
|
|
242
|
+
if (activeStatuses.includes(state.status)) {
|
|
243
|
+
return state;
|
|
244
|
+
}
|
|
245
|
+
// Keep first non-active match as fallback
|
|
246
|
+
if (!fallback) {
|
|
247
|
+
fallback = state;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return fallback;
|
|
252
|
+
}
|
|
253
|
+
// ============================================
|
|
254
|
+
// Worker Pool Operations
|
|
255
|
+
// ============================================
|
|
256
|
+
/**
|
|
257
|
+
* Mark a session as claimed by a worker
|
|
258
|
+
*
|
|
259
|
+
* @param linearSessionId - The Linear session ID
|
|
260
|
+
* @param workerId - The worker claiming the session
|
|
261
|
+
*/
|
|
262
|
+
export async function claimSession(linearSessionId, workerId) {
|
|
263
|
+
if (!isRedisConfigured()) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
const existing = await getSessionState(linearSessionId);
|
|
267
|
+
if (!existing) {
|
|
268
|
+
log.warn('Session not found for claim', { linearSessionId });
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (existing.status !== 'pending') {
|
|
272
|
+
log.warn('Session not in pending status', {
|
|
273
|
+
linearSessionId,
|
|
274
|
+
status: existing.status,
|
|
275
|
+
});
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const key = buildSessionKey(linearSessionId);
|
|
279
|
+
const now = Math.floor(Date.now() / 1000);
|
|
280
|
+
const updated = {
|
|
281
|
+
...existing,
|
|
282
|
+
status: 'claimed',
|
|
283
|
+
workerId,
|
|
284
|
+
claimedAt: now,
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
};
|
|
287
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
288
|
+
log.info('Session claimed', { linearSessionId, workerId });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Update session with worker info when work starts
|
|
293
|
+
*
|
|
294
|
+
* @param linearSessionId - The Linear session ID
|
|
295
|
+
* @param workerId - The worker processing the session
|
|
296
|
+
* @param worktreePath - Path to the git worktree
|
|
297
|
+
*/
|
|
298
|
+
export async function startSession(linearSessionId, workerId, worktreePath) {
|
|
299
|
+
if (!isRedisConfigured()) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const existing = await getSessionState(linearSessionId);
|
|
303
|
+
if (!existing) {
|
|
304
|
+
log.warn('Session not found for start', { linearSessionId });
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
const key = buildSessionKey(linearSessionId);
|
|
308
|
+
const now = Math.floor(Date.now() / 1000);
|
|
309
|
+
const updated = {
|
|
310
|
+
...existing,
|
|
311
|
+
status: 'running',
|
|
312
|
+
workerId,
|
|
313
|
+
worktreePath,
|
|
314
|
+
updatedAt: now,
|
|
315
|
+
};
|
|
316
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
317
|
+
log.info('Session started', { linearSessionId, workerId, worktreePath });
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Get all sessions from Redis
|
|
322
|
+
* For dashboard display
|
|
323
|
+
*/
|
|
324
|
+
export async function getAllSessions() {
|
|
325
|
+
if (!isRedisConfigured()) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const keys = await redisKeys(`${SESSION_KEY_PREFIX}*`);
|
|
330
|
+
const sessions = [];
|
|
331
|
+
for (const key of keys) {
|
|
332
|
+
const state = await redisGet(key);
|
|
333
|
+
if (state) {
|
|
334
|
+
sessions.push(state);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Sort by updatedAt descending (most recent first)
|
|
338
|
+
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
339
|
+
return sessions;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
log.error('Failed to get all sessions', { error });
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get sessions by status
|
|
348
|
+
*/
|
|
349
|
+
export async function getSessionsByStatus(status) {
|
|
350
|
+
const allSessions = await getAllSessions();
|
|
351
|
+
const statusArray = Array.isArray(status) ? status : [status];
|
|
352
|
+
return allSessions.filter((s) => statusArray.includes(s.status));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Transfer session ownership to a new worker
|
|
356
|
+
* Used when a worker re-registers after disconnection and gets a new ID
|
|
357
|
+
*
|
|
358
|
+
* @param linearSessionId - The Linear session ID
|
|
359
|
+
* @param newWorkerId - The new worker ID to assign
|
|
360
|
+
* @param oldWorkerId - The previous worker ID (for validation)
|
|
361
|
+
* @returns Whether the transfer was successful
|
|
362
|
+
*/
|
|
363
|
+
export async function transferSessionOwnership(linearSessionId, newWorkerId, oldWorkerId) {
|
|
364
|
+
if (!isRedisConfigured()) {
|
|
365
|
+
return { transferred: false, reason: 'Redis not configured' };
|
|
366
|
+
}
|
|
367
|
+
const existing = await getSessionState(linearSessionId);
|
|
368
|
+
if (!existing) {
|
|
369
|
+
return { transferred: false, reason: 'Session not found' };
|
|
370
|
+
}
|
|
371
|
+
// Validate that the old worker ID matches (security check)
|
|
372
|
+
if (existing.workerId && existing.workerId !== oldWorkerId) {
|
|
373
|
+
log.warn('Session ownership transfer rejected - worker ID mismatch', {
|
|
374
|
+
linearSessionId,
|
|
375
|
+
expectedWorkerId: oldWorkerId,
|
|
376
|
+
actualWorkerId: existing.workerId,
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
transferred: false,
|
|
380
|
+
reason: `Session owned by different worker: ${existing.workerId}`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const key = buildSessionKey(linearSessionId);
|
|
384
|
+
const now = Math.floor(Date.now() / 1000);
|
|
385
|
+
const updated = {
|
|
386
|
+
...existing,
|
|
387
|
+
workerId: newWorkerId,
|
|
388
|
+
updatedAt: now,
|
|
389
|
+
};
|
|
390
|
+
await redisSet(key, updated, SESSION_TTL_SECONDS);
|
|
391
|
+
log.info('Session ownership transferred', {
|
|
392
|
+
linearSessionId,
|
|
393
|
+
oldWorkerId,
|
|
394
|
+
newWorkerId,
|
|
395
|
+
});
|
|
396
|
+
return { transferred: true };
|
|
397
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Linear OAuth token lifecycle in Redis:
|
|
5
|
+
* - Store, retrieve, refresh, and revoke tokens
|
|
6
|
+
* - Automatic token refresh before expiration
|
|
7
|
+
* - Multi-workspace token support
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* OAuth token data stored in Redis
|
|
11
|
+
*/
|
|
12
|
+
export interface StoredToken {
|
|
13
|
+
/** The OAuth access token for API calls */
|
|
14
|
+
accessToken: string;
|
|
15
|
+
/** The refresh token for obtaining new access tokens */
|
|
16
|
+
refreshToken?: string;
|
|
17
|
+
/** Token type (usually "Bearer") */
|
|
18
|
+
tokenType: string;
|
|
19
|
+
/** OAuth scopes granted */
|
|
20
|
+
scope?: string;
|
|
21
|
+
/** Unix timestamp when the token expires */
|
|
22
|
+
expiresAt?: number;
|
|
23
|
+
/** Unix timestamp when the token was stored */
|
|
24
|
+
storedAt: number;
|
|
25
|
+
/** Workspace/organization ID this token belongs to */
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
/** Workspace name for display purposes */
|
|
28
|
+
workspaceName?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Response from Linear OAuth token exchange
|
|
32
|
+
*/
|
|
33
|
+
export interface LinearTokenResponse {
|
|
34
|
+
access_token: string;
|
|
35
|
+
refresh_token?: string;
|
|
36
|
+
token_type: string;
|
|
37
|
+
expires_in?: number;
|
|
38
|
+
scope?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Organization info from Linear API
|
|
42
|
+
*/
|
|
43
|
+
export interface LinearOrganization {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
urlKey: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Store OAuth token for a workspace in Redis
|
|
50
|
+
*
|
|
51
|
+
* @param workspaceId - The Linear organization ID
|
|
52
|
+
* @param tokenResponse - The token data from OAuth exchange
|
|
53
|
+
* @param workspaceName - Optional workspace name for display
|
|
54
|
+
*/
|
|
55
|
+
export declare function storeToken(workspaceId: string, tokenResponse: LinearTokenResponse, workspaceName?: string): Promise<StoredToken>;
|
|
56
|
+
/**
|
|
57
|
+
* Retrieve OAuth token for a workspace from Redis
|
|
58
|
+
*
|
|
59
|
+
* @param workspaceId - The Linear organization ID
|
|
60
|
+
* @returns The stored token or null if not found
|
|
61
|
+
*/
|
|
62
|
+
export declare function getToken(workspaceId: string): Promise<StoredToken | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Check if a token needs to be refreshed
|
|
65
|
+
* Returns true if token expires within the buffer period
|
|
66
|
+
*
|
|
67
|
+
* @param token - The stored token to check
|
|
68
|
+
* @returns Whether the token should be refreshed
|
|
69
|
+
*/
|
|
70
|
+
export declare function shouldRefreshToken(token: StoredToken): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Refresh an OAuth token using the refresh token
|
|
73
|
+
*
|
|
74
|
+
* @param token - The current stored token with refresh token
|
|
75
|
+
* @param clientId - The Linear OAuth client ID
|
|
76
|
+
* @param clientSecret - The Linear OAuth client secret
|
|
77
|
+
* @returns The new stored token or null if refresh failed
|
|
78
|
+
*/
|
|
79
|
+
export declare function refreshToken(token: StoredToken, clientId: string, clientSecret: string): Promise<StoredToken | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Get a valid access token for a workspace, refreshing if necessary
|
|
82
|
+
*
|
|
83
|
+
* @param workspaceId - The Linear organization ID
|
|
84
|
+
* @param clientId - Optional OAuth client ID for refresh (defaults to env var)
|
|
85
|
+
* @param clientSecret - Optional OAuth client secret for refresh (defaults to env var)
|
|
86
|
+
* @returns The access token or null if not available
|
|
87
|
+
*/
|
|
88
|
+
export declare function getAccessToken(workspaceId: string, clientId?: string, clientSecret?: string): Promise<string | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Delete a token from Redis (for cleanup or revocation)
|
|
91
|
+
*
|
|
92
|
+
* @param workspaceId - The Linear organization ID
|
|
93
|
+
* @returns Whether the deletion was successful
|
|
94
|
+
*/
|
|
95
|
+
export declare function deleteToken(workspaceId: string): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* List all stored workspace tokens (for admin purposes)
|
|
98
|
+
* Note: This scans all keys with the token prefix
|
|
99
|
+
*
|
|
100
|
+
* @returns Array of workspace IDs with stored tokens
|
|
101
|
+
*/
|
|
102
|
+
export declare function listStoredWorkspaces(): Promise<string[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Clean up expired tokens from Redis storage
|
|
105
|
+
* Should be called periodically (e.g., via cron job)
|
|
106
|
+
*
|
|
107
|
+
* @returns Number of tokens cleaned up
|
|
108
|
+
*/
|
|
109
|
+
export declare function cleanupExpiredTokens(): Promise<number>;
|
|
110
|
+
/**
|
|
111
|
+
* Fetch the current user's organization from Linear API
|
|
112
|
+
* Used after OAuth to determine which workspace the token belongs to
|
|
113
|
+
*
|
|
114
|
+
* @param accessToken - The OAuth access token
|
|
115
|
+
* @returns Organization info or null if fetch failed
|
|
116
|
+
*/
|
|
117
|
+
export declare function fetchOrganization(accessToken: string): Promise<LinearOrganization | null>;
|
|
118
|
+
//# sourceMappingURL=token-storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-storage.d.ts","sourceRoot":"","sources":["../../src/token-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAA;IACnB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAA;IACjB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAA;IAChB,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,0CAA0C;IAC1C,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAoBD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,mBAAmB,EAClC,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC,CAsBtB;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAU/E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAU9D;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAgD7B;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,MAAM,EACjB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8BxB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAYvE;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAQ9D;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC,CAsB5D;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CA6CpC"}
|