@supaku/agentfactory-server 0.1.2 → 0.2.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/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/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +16 -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 +335 -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/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/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/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/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/package.json +3 -3
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphan Cleanup Module
|
|
3
|
+
*
|
|
4
|
+
* Detects and handles orphaned sessions - sessions marked as running/claimed
|
|
5
|
+
* but whose worker is no longer active (heartbeat timeout).
|
|
6
|
+
*
|
|
7
|
+
* When a worker disconnects, the work is re-queued for another worker to resume.
|
|
8
|
+
* The Linear issue status is NOT rolled back - the issue remains in its current
|
|
9
|
+
* workflow state and the next worker will resume from where the previous one left off.
|
|
10
|
+
*/
|
|
11
|
+
import { createLogger } from './logger';
|
|
12
|
+
import { getAllSessions, resetSessionForRequeue, } from './session-storage';
|
|
13
|
+
import { listWorkers } from './worker-storage';
|
|
14
|
+
import { releaseClaim, isSessionInQueue, } from './work-queue';
|
|
15
|
+
import { dispatchWork, cleanupExpiredLocksWithPendingWork, cleanupStaleLocksWithIdleWorkers, isSessionParkedForIssue, } from './issue-lock';
|
|
16
|
+
const log = createLogger('orphan-cleanup');
|
|
17
|
+
// How long a session can be running without a valid worker before being considered orphaned
|
|
18
|
+
const ORPHAN_THRESHOLD_MS = 120_000; // 2 minutes (worker TTL + buffer)
|
|
19
|
+
/**
|
|
20
|
+
* Find sessions that are orphaned (running/claimed but worker is gone)
|
|
21
|
+
*/
|
|
22
|
+
export async function findOrphanedSessions() {
|
|
23
|
+
const [sessions, workers] = await Promise.all([
|
|
24
|
+
getAllSessions(),
|
|
25
|
+
listWorkers(),
|
|
26
|
+
]);
|
|
27
|
+
// Build set of active worker IDs
|
|
28
|
+
const activeWorkerIds = new Set(workers
|
|
29
|
+
.filter((w) => w.status === 'active')
|
|
30
|
+
.map((w) => w.id));
|
|
31
|
+
const orphaned = [];
|
|
32
|
+
for (const session of sessions) {
|
|
33
|
+
// Only check running or claimed sessions
|
|
34
|
+
if (session.status !== 'running' && session.status !== 'claimed') {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// If session has no worker assigned, it's orphaned
|
|
38
|
+
if (!session.workerId) {
|
|
39
|
+
log.debug('Session has no worker assigned', {
|
|
40
|
+
sessionId: session.linearSessionId,
|
|
41
|
+
status: session.status,
|
|
42
|
+
});
|
|
43
|
+
orphaned.push(session);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// If the assigned worker is no longer active, session is orphaned
|
|
47
|
+
if (!activeWorkerIds.has(session.workerId)) {
|
|
48
|
+
log.debug('Session worker is no longer active', {
|
|
49
|
+
sessionId: session.linearSessionId,
|
|
50
|
+
workerId: session.workerId,
|
|
51
|
+
status: session.status,
|
|
52
|
+
});
|
|
53
|
+
orphaned.push(session);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return orphaned;
|
|
58
|
+
}
|
|
59
|
+
// How long a pending session can exist without a queue entry before being considered a zombie
|
|
60
|
+
const ZOMBIE_PENDING_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
61
|
+
/**
|
|
62
|
+
* Find zombie pending sessions — sessions stuck in `pending` status
|
|
63
|
+
* that have no corresponding entry in the work queue or any issue-pending queue.
|
|
64
|
+
*
|
|
65
|
+
* These arise when:
|
|
66
|
+
* - claimWork() removes from queue, but claimSession() fails and requeue also fails
|
|
67
|
+
* - Issue lock expires but promotion fails silently
|
|
68
|
+
*/
|
|
69
|
+
export async function findZombiePendingSessions() {
|
|
70
|
+
const sessions = await getAllSessions();
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const zombies = [];
|
|
73
|
+
for (const session of sessions) {
|
|
74
|
+
if (session.status !== 'pending')
|
|
75
|
+
continue;
|
|
76
|
+
// Only consider sessions older than the threshold
|
|
77
|
+
const age = now - session.updatedAt;
|
|
78
|
+
if (age < ZOMBIE_PENDING_THRESHOLD_MS)
|
|
79
|
+
continue;
|
|
80
|
+
// Check if session is in the global work queue
|
|
81
|
+
const inQueue = await isSessionInQueue(session.linearSessionId);
|
|
82
|
+
if (inQueue)
|
|
83
|
+
continue;
|
|
84
|
+
// Check if session is parked in the issue-pending queue
|
|
85
|
+
const parked = await isSessionParkedForIssue(session.issueId, session.linearSessionId);
|
|
86
|
+
if (parked)
|
|
87
|
+
continue;
|
|
88
|
+
// Session is pending but not in any queue — it's a zombie
|
|
89
|
+
log.warn('Found zombie pending session', {
|
|
90
|
+
sessionId: session.linearSessionId,
|
|
91
|
+
issueIdentifier: session.issueIdentifier,
|
|
92
|
+
ageMinutes: Math.round(age / 60_000),
|
|
93
|
+
});
|
|
94
|
+
zombies.push(session);
|
|
95
|
+
}
|
|
96
|
+
return zombies;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Clean up orphaned sessions by re-queuing them
|
|
100
|
+
*
|
|
101
|
+
* @param callbacks - Optional callbacks for external integrations (e.g., posting Linear comments)
|
|
102
|
+
*/
|
|
103
|
+
export async function cleanupOrphanedSessions(callbacks) {
|
|
104
|
+
const result = {
|
|
105
|
+
checked: 0,
|
|
106
|
+
orphaned: 0,
|
|
107
|
+
requeued: 0,
|
|
108
|
+
failed: 0,
|
|
109
|
+
details: [],
|
|
110
|
+
worktreePathsToCleanup: [],
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const sessions = await getAllSessions();
|
|
114
|
+
result.checked = sessions.length;
|
|
115
|
+
const orphaned = await findOrphanedSessions();
|
|
116
|
+
result.orphaned = orphaned.length;
|
|
117
|
+
if (orphaned.length > 0) {
|
|
118
|
+
log.info('Found orphaned sessions', { count: orphaned.length });
|
|
119
|
+
}
|
|
120
|
+
for (const session of orphaned) {
|
|
121
|
+
try {
|
|
122
|
+
const issueIdentifier = session.issueIdentifier || session.issueId.slice(0, 8);
|
|
123
|
+
log.info('Re-queuing orphaned session', {
|
|
124
|
+
sessionId: session.linearSessionId,
|
|
125
|
+
issueIdentifier,
|
|
126
|
+
previousWorker: session.workerId,
|
|
127
|
+
previousStatus: session.status,
|
|
128
|
+
});
|
|
129
|
+
// Release any existing claim
|
|
130
|
+
await releaseClaim(session.linearSessionId);
|
|
131
|
+
// Reset session for requeue (clears workerId so new worker can claim)
|
|
132
|
+
await resetSessionForRequeue(session.linearSessionId);
|
|
133
|
+
// Re-queue the work with higher priority
|
|
134
|
+
// IMPORTANT: Preserve workType to prevent incorrect status transitions
|
|
135
|
+
// NOTE: Do NOT preserve claudeSessionId - the old session may be corrupted
|
|
136
|
+
// from the crash that caused the orphan. Starting fresh is safer.
|
|
137
|
+
const work = {
|
|
138
|
+
sessionId: session.linearSessionId,
|
|
139
|
+
issueId: session.issueId,
|
|
140
|
+
issueIdentifier,
|
|
141
|
+
priority: Math.max(1, (session.priority || 3) - 1), // Boost priority
|
|
142
|
+
queuedAt: Date.now(),
|
|
143
|
+
prompt: session.promptContext,
|
|
144
|
+
// claudeSessionId intentionally omitted - don't resume crashed sessions
|
|
145
|
+
workType: session.workType,
|
|
146
|
+
};
|
|
147
|
+
const dispatchResult = await dispatchWork(work);
|
|
148
|
+
if (dispatchResult.dispatched || dispatchResult.parked) {
|
|
149
|
+
result.requeued++;
|
|
150
|
+
result.details.push({
|
|
151
|
+
sessionId: session.linearSessionId,
|
|
152
|
+
issueIdentifier,
|
|
153
|
+
action: 'requeued',
|
|
154
|
+
worktreePath: session.worktreePath,
|
|
155
|
+
});
|
|
156
|
+
// Track worktree path for cleanup on worker machines
|
|
157
|
+
if (session.worktreePath) {
|
|
158
|
+
result.worktreePathsToCleanup.push(session.worktreePath);
|
|
159
|
+
}
|
|
160
|
+
// Call external callback (e.g., post Linear comment)
|
|
161
|
+
if (callbacks?.onOrphanRequeued) {
|
|
162
|
+
try {
|
|
163
|
+
await callbacks.onOrphanRequeued(session);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
log.warn('onOrphanRequeued callback failed', { error: err });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
result.failed++;
|
|
172
|
+
result.details.push({
|
|
173
|
+
sessionId: session.linearSessionId,
|
|
174
|
+
issueIdentifier,
|
|
175
|
+
action: 'failed',
|
|
176
|
+
reason: 'Failed to queue work',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
log.error('Failed to cleanup orphaned session', {
|
|
182
|
+
sessionId: session.linearSessionId,
|
|
183
|
+
error: err,
|
|
184
|
+
});
|
|
185
|
+
result.failed++;
|
|
186
|
+
result.details.push({
|
|
187
|
+
sessionId: session.linearSessionId,
|
|
188
|
+
issueIdentifier: session.issueIdentifier || 'unknown',
|
|
189
|
+
action: 'failed',
|
|
190
|
+
reason: err instanceof Error ? err.message : 'Unknown error',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Check for zombie pending sessions (pending but not in any queue)
|
|
195
|
+
try {
|
|
196
|
+
const zombies = await findZombiePendingSessions();
|
|
197
|
+
if (zombies.length > 0) {
|
|
198
|
+
log.info('Found zombie pending sessions', { count: zombies.length });
|
|
199
|
+
}
|
|
200
|
+
for (const session of zombies) {
|
|
201
|
+
try {
|
|
202
|
+
const issueIdentifier = session.issueIdentifier || session.issueId.slice(0, 8);
|
|
203
|
+
log.info('Re-dispatching zombie pending session', {
|
|
204
|
+
sessionId: session.linearSessionId,
|
|
205
|
+
issueIdentifier,
|
|
206
|
+
});
|
|
207
|
+
const work = {
|
|
208
|
+
sessionId: session.linearSessionId,
|
|
209
|
+
issueId: session.issueId,
|
|
210
|
+
issueIdentifier,
|
|
211
|
+
priority: Math.max(1, (session.priority || 3) - 1),
|
|
212
|
+
queuedAt: Date.now(),
|
|
213
|
+
prompt: session.promptContext,
|
|
214
|
+
workType: session.workType,
|
|
215
|
+
};
|
|
216
|
+
const dispatchResult = await dispatchWork(work);
|
|
217
|
+
if (dispatchResult.dispatched || dispatchResult.parked) {
|
|
218
|
+
result.requeued++;
|
|
219
|
+
result.details.push({
|
|
220
|
+
sessionId: session.linearSessionId,
|
|
221
|
+
issueIdentifier,
|
|
222
|
+
action: 'requeued',
|
|
223
|
+
reason: 'Zombie pending session recovered',
|
|
224
|
+
});
|
|
225
|
+
// Call external callback
|
|
226
|
+
if (callbacks?.onZombieRecovered) {
|
|
227
|
+
try {
|
|
228
|
+
await callbacks.onZombieRecovered(session);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
log.warn('onZombieRecovered callback failed', { error: err });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result.failed++;
|
|
237
|
+
result.details.push({
|
|
238
|
+
sessionId: session.linearSessionId,
|
|
239
|
+
issueIdentifier,
|
|
240
|
+
action: 'failed',
|
|
241
|
+
reason: 'Failed to re-dispatch zombie session',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
log.error('Failed to recover zombie session', {
|
|
247
|
+
sessionId: session.linearSessionId,
|
|
248
|
+
error: err,
|
|
249
|
+
});
|
|
250
|
+
result.failed++;
|
|
251
|
+
result.details.push({
|
|
252
|
+
sessionId: session.linearSessionId,
|
|
253
|
+
issueIdentifier: session.issueIdentifier || 'unknown',
|
|
254
|
+
action: 'failed',
|
|
255
|
+
reason: err instanceof Error ? err.message : 'Unknown error',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
log.error('Failed to find zombie pending sessions', { error: err });
|
|
262
|
+
}
|
|
263
|
+
// Also check for expired issue locks with pending work
|
|
264
|
+
try {
|
|
265
|
+
const promoted = await cleanupExpiredLocksWithPendingWork();
|
|
266
|
+
if (promoted > 0) {
|
|
267
|
+
log.info('Promoted pending work from expired issue locks', { promoted });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
log.error('Failed to cleanup expired issue locks', { error: err });
|
|
272
|
+
}
|
|
273
|
+
// Check for stale locks held by completed sessions when workers have idle capacity.
|
|
274
|
+
// Only runs when workers are online — no point promoting if nobody can pick it up.
|
|
275
|
+
try {
|
|
276
|
+
const workers = await listWorkers();
|
|
277
|
+
const activeWorkers = workers.filter((w) => w.status === 'active');
|
|
278
|
+
const hasIdleWorkers = activeWorkers.length > 0 &&
|
|
279
|
+
activeWorkers.some((w) => w.activeCount < w.capacity);
|
|
280
|
+
if (hasIdleWorkers) {
|
|
281
|
+
const promoted = await cleanupStaleLocksWithIdleWorkers(hasIdleWorkers);
|
|
282
|
+
if (promoted > 0) {
|
|
283
|
+
log.info('Promoted parked work from stale issue locks', { promoted });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
log.error('Failed to cleanup stale issue locks', { error: err });
|
|
289
|
+
}
|
|
290
|
+
log.info('Orphan cleanup completed', {
|
|
291
|
+
checked: result.checked,
|
|
292
|
+
orphaned: result.orphaned,
|
|
293
|
+
requeued: result.requeued,
|
|
294
|
+
failed: result.failed,
|
|
295
|
+
worktreePathsToCleanup: result.worktreePathsToCleanup.length,
|
|
296
|
+
});
|
|
297
|
+
// Log worktree cleanup info if any paths need attention
|
|
298
|
+
if (result.worktreePathsToCleanup.length > 0) {
|
|
299
|
+
log.info('Worktree cleanup needed on worker machines', {
|
|
300
|
+
paths: result.worktreePathsToCleanup,
|
|
301
|
+
note: 'Run cleanup-worktrees on each worker machine to remove orphaned worktrees',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
log.error('Orphan cleanup failed', { error: err });
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Check if cleanup should run based on time since last cleanup
|
|
312
|
+
* Returns true if enough time has passed
|
|
313
|
+
*/
|
|
314
|
+
let lastCleanupTime = 0;
|
|
315
|
+
const CLEANUP_INTERVAL_MS = 60_000; // Run at most once per minute
|
|
316
|
+
export function shouldRunCleanup() {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (now - lastCleanupTime >= CLEANUP_INTERVAL_MS) {
|
|
319
|
+
lastCleanupTime = now;
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Run cleanup if enough time has passed (debounced)
|
|
326
|
+
* Safe to call frequently - will only actually run periodically
|
|
327
|
+
*
|
|
328
|
+
* @param callbacks - Optional callbacks for external integrations
|
|
329
|
+
*/
|
|
330
|
+
export async function maybeCleanupOrphans(callbacks) {
|
|
331
|
+
if (!shouldRunCleanup()) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return cleanupOrphanedSessions(callbacks);
|
|
335
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending Prompts Module
|
|
3
|
+
*
|
|
4
|
+
* Stores follow-up prompts for running agent sessions.
|
|
5
|
+
* Workers poll for pending prompts and forward them to their running Claude processes.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* A pending prompt waiting to be delivered to a running agent
|
|
9
|
+
*/
|
|
10
|
+
export interface PendingPrompt {
|
|
11
|
+
id: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
issueId: string;
|
|
14
|
+
prompt: string;
|
|
15
|
+
userId?: string;
|
|
16
|
+
userName?: string;
|
|
17
|
+
createdAt: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Store a pending prompt for a session
|
|
21
|
+
*
|
|
22
|
+
* @param sessionId - The Linear session ID
|
|
23
|
+
* @param issueId - The Linear issue ID
|
|
24
|
+
* @param prompt - The prompt text from the user
|
|
25
|
+
* @param user - Optional user info
|
|
26
|
+
* @returns The created prompt or null if storage failed
|
|
27
|
+
*/
|
|
28
|
+
export declare function storePendingPrompt(sessionId: string, issueId: string, prompt: string, user?: {
|
|
29
|
+
id?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
}): Promise<PendingPrompt | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Get all pending prompts for a session
|
|
34
|
+
*
|
|
35
|
+
* @param sessionId - The Linear session ID
|
|
36
|
+
* @returns Array of pending prompts (oldest first)
|
|
37
|
+
*/
|
|
38
|
+
export declare function getPendingPrompts(sessionId: string): Promise<PendingPrompt[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Get the count of pending prompts for a session
|
|
41
|
+
*/
|
|
42
|
+
export declare function getPendingPromptCount(sessionId: string): Promise<number>;
|
|
43
|
+
/**
|
|
44
|
+
* Claim and remove a pending prompt by ID
|
|
45
|
+
* Returns the prompt if found and removed, null otherwise
|
|
46
|
+
*
|
|
47
|
+
* @param sessionId - The Linear session ID
|
|
48
|
+
* @param promptId - The prompt ID to claim
|
|
49
|
+
* @returns The claimed prompt or null
|
|
50
|
+
*/
|
|
51
|
+
export declare function claimPendingPrompt(sessionId: string, promptId: string): Promise<PendingPrompt | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Pop the oldest pending prompt for a session (claim and remove atomically)
|
|
54
|
+
*
|
|
55
|
+
* @param sessionId - The Linear session ID
|
|
56
|
+
* @returns The oldest pending prompt or null if none
|
|
57
|
+
*/
|
|
58
|
+
export declare function popPendingPrompt(sessionId: string): Promise<PendingPrompt | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Clear all pending prompts for a session
|
|
61
|
+
* Called when session completes or is stopped
|
|
62
|
+
*
|
|
63
|
+
* @param sessionId - The Linear session ID
|
|
64
|
+
* @returns true if cleared successfully
|
|
65
|
+
*/
|
|
66
|
+
export declare function clearPendingPrompts(sessionId: string): Promise<boolean>;
|
|
67
|
+
//# sourceMappingURL=pending-prompts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pending-prompts.d.ts","sourceRoot":"","sources":["../../src/pending-prompts.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAiBH;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAgBD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAiC/B;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAanF;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY9E;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAwB/B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAkBvF;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc7E"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending Prompts Module
|
|
3
|
+
*
|
|
4
|
+
* Stores follow-up prompts for running agent sessions.
|
|
5
|
+
* Workers poll for pending prompts and forward them to their running Claude processes.
|
|
6
|
+
*/
|
|
7
|
+
import { redisRPush, redisLRange, redisLRem, redisLLen, redisDel, isRedisConfigured, } from './redis';
|
|
8
|
+
import { createLogger } from './logger';
|
|
9
|
+
const log = createLogger('pending-prompts');
|
|
10
|
+
// Redis key prefix for pending prompts per session
|
|
11
|
+
const PENDING_PROMPTS_PREFIX = 'session:prompts:';
|
|
12
|
+
/**
|
|
13
|
+
* Build the Redis key for a session's pending prompts
|
|
14
|
+
*/
|
|
15
|
+
function buildPromptsKey(sessionId) {
|
|
16
|
+
return `${PENDING_PROMPTS_PREFIX}${sessionId}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Generate a unique prompt ID
|
|
20
|
+
*/
|
|
21
|
+
function generatePromptId() {
|
|
22
|
+
return `prm_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Store a pending prompt for a session
|
|
26
|
+
*
|
|
27
|
+
* @param sessionId - The Linear session ID
|
|
28
|
+
* @param issueId - The Linear issue ID
|
|
29
|
+
* @param prompt - The prompt text from the user
|
|
30
|
+
* @param user - Optional user info
|
|
31
|
+
* @returns The created prompt or null if storage failed
|
|
32
|
+
*/
|
|
33
|
+
export async function storePendingPrompt(sessionId, issueId, prompt, user) {
|
|
34
|
+
if (!isRedisConfigured()) {
|
|
35
|
+
log.warn('Redis not configured, cannot store pending prompt');
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const pendingPrompt = {
|
|
40
|
+
id: generatePromptId(),
|
|
41
|
+
sessionId,
|
|
42
|
+
issueId,
|
|
43
|
+
prompt,
|
|
44
|
+
userId: user?.id,
|
|
45
|
+
userName: user?.name,
|
|
46
|
+
createdAt: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
const key = buildPromptsKey(sessionId);
|
|
49
|
+
const serialized = JSON.stringify(pendingPrompt);
|
|
50
|
+
await redisRPush(key, serialized);
|
|
51
|
+
log.info('Pending prompt stored', {
|
|
52
|
+
promptId: pendingPrompt.id,
|
|
53
|
+
sessionId,
|
|
54
|
+
issueId,
|
|
55
|
+
promptLength: prompt.length,
|
|
56
|
+
});
|
|
57
|
+
return pendingPrompt;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
log.error('Failed to store pending prompt', { error, sessionId, issueId });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get all pending prompts for a session
|
|
66
|
+
*
|
|
67
|
+
* @param sessionId - The Linear session ID
|
|
68
|
+
* @returns Array of pending prompts (oldest first)
|
|
69
|
+
*/
|
|
70
|
+
export async function getPendingPrompts(sessionId) {
|
|
71
|
+
if (!isRedisConfigured()) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const key = buildPromptsKey(sessionId);
|
|
76
|
+
const items = await redisLRange(key, 0, -1);
|
|
77
|
+
return items.map((item) => JSON.parse(item));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
log.error('Failed to get pending prompts', { error, sessionId });
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get the count of pending prompts for a session
|
|
86
|
+
*/
|
|
87
|
+
export async function getPendingPromptCount(sessionId) {
|
|
88
|
+
if (!isRedisConfigured()) {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const key = buildPromptsKey(sessionId);
|
|
93
|
+
return await redisLLen(key);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
log.error('Failed to get pending prompt count', { error, sessionId });
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Claim and remove a pending prompt by ID
|
|
102
|
+
* Returns the prompt if found and removed, null otherwise
|
|
103
|
+
*
|
|
104
|
+
* @param sessionId - The Linear session ID
|
|
105
|
+
* @param promptId - The prompt ID to claim
|
|
106
|
+
* @returns The claimed prompt or null
|
|
107
|
+
*/
|
|
108
|
+
export async function claimPendingPrompt(sessionId, promptId) {
|
|
109
|
+
if (!isRedisConfigured()) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const key = buildPromptsKey(sessionId);
|
|
114
|
+
const items = await redisLRange(key, 0, -1);
|
|
115
|
+
for (const item of items) {
|
|
116
|
+
const prompt = JSON.parse(item);
|
|
117
|
+
if (prompt.id === promptId) {
|
|
118
|
+
// Remove this specific item from the list
|
|
119
|
+
await redisLRem(key, 1, item);
|
|
120
|
+
log.info('Pending prompt claimed', { promptId, sessionId });
|
|
121
|
+
return prompt;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
log.error('Failed to claim pending prompt', { error, sessionId, promptId });
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Pop the oldest pending prompt for a session (claim and remove atomically)
|
|
133
|
+
*
|
|
134
|
+
* @param sessionId - The Linear session ID
|
|
135
|
+
* @returns The oldest pending prompt or null if none
|
|
136
|
+
*/
|
|
137
|
+
export async function popPendingPrompt(sessionId) {
|
|
138
|
+
if (!isRedisConfigured()) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const prompts = await getPendingPrompts(sessionId);
|
|
143
|
+
if (prompts.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const oldest = prompts[0];
|
|
147
|
+
const claimed = await claimPendingPrompt(sessionId, oldest.id);
|
|
148
|
+
return claimed;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log.error('Failed to pop pending prompt', { error, sessionId });
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Clear all pending prompts for a session
|
|
157
|
+
* Called when session completes or is stopped
|
|
158
|
+
*
|
|
159
|
+
* @param sessionId - The Linear session ID
|
|
160
|
+
* @returns true if cleared successfully
|
|
161
|
+
*/
|
|
162
|
+
export async function clearPendingPrompts(sessionId) {
|
|
163
|
+
if (!isRedisConfigured()) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const key = buildPromptsKey(sessionId);
|
|
168
|
+
await redisDel(key);
|
|
169
|
+
log.info('Pending prompts cleared', { sessionId });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
log.error('Failed to clear pending prompts', { error, sessionId });
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -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"}
|