@supaku/agentfactory-server 0.1.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.
@@ -0,0 +1,343 @@
1
+ import Redis from 'ioredis';
2
+ const log = {
3
+ info: (msg, data) => console.log(`[redis] ${msg}`, data ? JSON.stringify(data) : ''),
4
+ warn: (msg, data) => console.warn(`[redis] ${msg}`, data ? JSON.stringify(data) : ''),
5
+ error: (msg, data) => console.error(`[redis] ${msg}`, data ? JSON.stringify(data) : ''),
6
+ debug: (_msg, _data) => { },
7
+ };
8
+ let _redis = null;
9
+ /**
10
+ * Parse Redis URL using the modern URL class to avoid deprecated url.parse()
11
+ * Supports redis:// and rediss:// (TLS) protocols
12
+ */
13
+ function parseRedisUrl(redisUrl) {
14
+ const url = new URL(redisUrl);
15
+ const options = {};
16
+ if (url.hostname) {
17
+ options.host = url.hostname;
18
+ }
19
+ if (url.port) {
20
+ options.port = parseInt(url.port, 10);
21
+ }
22
+ if (url.username && url.username !== 'default') {
23
+ options.username = decodeURIComponent(url.username);
24
+ }
25
+ if (url.password) {
26
+ options.password = decodeURIComponent(url.password);
27
+ }
28
+ // Database number from path (e.g., redis://host/0)
29
+ if (url.pathname && url.pathname.length > 1) {
30
+ const db = parseInt(url.pathname.slice(1), 10);
31
+ if (!isNaN(db)) {
32
+ options.db = db;
33
+ }
34
+ }
35
+ // Enable TLS for rediss:// protocol
36
+ if (url.protocol === 'rediss:') {
37
+ options.tls = {};
38
+ }
39
+ // Parse query parameters (e.g., ?family=6)
40
+ for (const [key, value] of url.searchParams) {
41
+ if (key === 'family') {
42
+ const family = parseInt(value, 10);
43
+ if (!isNaN(family)) {
44
+ options.family = family;
45
+ }
46
+ }
47
+ }
48
+ return options;
49
+ }
50
+ /**
51
+ * Check if Redis is configured via REDIS_URL
52
+ */
53
+ export function isRedisConfigured() {
54
+ return !!process.env.REDIS_URL;
55
+ }
56
+ /**
57
+ * Get the shared Redis client instance
58
+ * Lazily initialized to avoid errors during build
59
+ */
60
+ export function getRedisClient() {
61
+ if (!_redis) {
62
+ const redisUrl = process.env.REDIS_URL;
63
+ if (!redisUrl) {
64
+ throw new Error('REDIS_URL not set - Redis operations will fail');
65
+ }
66
+ const urlOptions = parseRedisUrl(redisUrl);
67
+ _redis = new Redis({
68
+ ...urlOptions,
69
+ maxRetriesPerRequest: 3,
70
+ lazyConnect: true,
71
+ });
72
+ _redis.on('error', (err) => {
73
+ log.error('Redis connection error', { error: err });
74
+ });
75
+ _redis.on('connect', () => {
76
+ log.info('Redis connected');
77
+ });
78
+ }
79
+ return _redis;
80
+ }
81
+ /**
82
+ * Disconnect Redis client (for graceful shutdown)
83
+ */
84
+ export async function disconnectRedis() {
85
+ if (_redis) {
86
+ await _redis.quit();
87
+ _redis = null;
88
+ log.info('Redis disconnected');
89
+ }
90
+ }
91
+ /**
92
+ * Set a value with optional TTL (seconds)
93
+ */
94
+ export async function redisSet(key, value, ttlSeconds) {
95
+ const redis = getRedisClient();
96
+ const serialized = JSON.stringify(value);
97
+ if (ttlSeconds) {
98
+ await redis.setex(key, ttlSeconds, serialized);
99
+ }
100
+ else {
101
+ await redis.set(key, serialized);
102
+ }
103
+ }
104
+ /**
105
+ * Get a typed value
106
+ */
107
+ export async function redisGet(key) {
108
+ const redis = getRedisClient();
109
+ const value = await redis.get(key);
110
+ if (value === null) {
111
+ return null;
112
+ }
113
+ return JSON.parse(value);
114
+ }
115
+ /**
116
+ * Delete a key
117
+ * @returns number of keys deleted (0 or 1)
118
+ */
119
+ export async function redisDel(key) {
120
+ const redis = getRedisClient();
121
+ return redis.del(key);
122
+ }
123
+ /**
124
+ * Check if a key exists
125
+ */
126
+ export async function redisExists(key) {
127
+ const redis = getRedisClient();
128
+ const result = await redis.exists(key);
129
+ return result === 1;
130
+ }
131
+ /**
132
+ * Get keys matching a pattern
133
+ */
134
+ export async function redisKeys(pattern) {
135
+ const redis = getRedisClient();
136
+ return redis.keys(pattern);
137
+ }
138
+ // ============================================
139
+ // List Operations (for work queue)
140
+ // ============================================
141
+ /**
142
+ * Push value to the right of a list (RPUSH)
143
+ * @returns length of list after push
144
+ */
145
+ export async function redisRPush(key, value) {
146
+ const redis = getRedisClient();
147
+ return redis.rpush(key, value);
148
+ }
149
+ /**
150
+ * Pop value from the left of a list (LPOP)
151
+ * @returns the popped value or null if list is empty
152
+ */
153
+ export async function redisLPop(key) {
154
+ const redis = getRedisClient();
155
+ return redis.lpop(key);
156
+ }
157
+ /**
158
+ * Get a range of elements from a list (LRANGE)
159
+ * @param start - Start index (0-based, inclusive)
160
+ * @param stop - Stop index (inclusive, -1 for end)
161
+ */
162
+ export async function redisLRange(key, start, stop) {
163
+ const redis = getRedisClient();
164
+ return redis.lrange(key, start, stop);
165
+ }
166
+ /**
167
+ * Get the length of a list (LLEN)
168
+ */
169
+ export async function redisLLen(key) {
170
+ const redis = getRedisClient();
171
+ return redis.llen(key);
172
+ }
173
+ /**
174
+ * Remove elements from a list (LREM)
175
+ * @param count - Number of occurrences to remove (0 = all)
176
+ * @returns number of elements removed
177
+ */
178
+ export async function redisLRem(key, count, value) {
179
+ const redis = getRedisClient();
180
+ return redis.lrem(key, count, value);
181
+ }
182
+ // ============================================
183
+ // Set Operations (for worker sessions)
184
+ // ============================================
185
+ /**
186
+ * Add member to a set (SADD)
187
+ * @returns number of elements added (0 if already exists)
188
+ */
189
+ export async function redisSAdd(key, member) {
190
+ const redis = getRedisClient();
191
+ return redis.sadd(key, member);
192
+ }
193
+ /**
194
+ * Remove member from a set (SREM)
195
+ * @returns number of elements removed
196
+ */
197
+ export async function redisSRem(key, member) {
198
+ const redis = getRedisClient();
199
+ return redis.srem(key, member);
200
+ }
201
+ /**
202
+ * Get all members of a set (SMEMBERS)
203
+ */
204
+ export async function redisSMembers(key) {
205
+ const redis = getRedisClient();
206
+ return redis.smembers(key);
207
+ }
208
+ /**
209
+ * Get the number of members in a set (SCARD)
210
+ */
211
+ export async function redisSCard(key) {
212
+ const redis = getRedisClient();
213
+ return redis.scard(key);
214
+ }
215
+ // ============================================
216
+ // Atomic Operations
217
+ // ============================================
218
+ /**
219
+ * Set a value only if key does not exist (SETNX)
220
+ * @returns true if key was set, false if it already existed
221
+ */
222
+ export async function redisSetNX(key, value, ttlSeconds) {
223
+ const redis = getRedisClient();
224
+ if (ttlSeconds) {
225
+ // Use SET with NX and EX options for atomic set-if-not-exists with TTL
226
+ const result = await redis.set(key, value, 'EX', ttlSeconds, 'NX');
227
+ return result === 'OK';
228
+ }
229
+ else {
230
+ const result = await redis.setnx(key, value);
231
+ return result === 1;
232
+ }
233
+ }
234
+ /**
235
+ * Set TTL on an existing key (EXPIRE)
236
+ * @returns true if TTL was set, false if key doesn't exist
237
+ */
238
+ export async function redisExpire(key, ttlSeconds) {
239
+ const redis = getRedisClient();
240
+ const result = await redis.expire(key, ttlSeconds);
241
+ return result === 1;
242
+ }
243
+ // ============================================
244
+ // Sorted Set Operations (for priority queue)
245
+ // ============================================
246
+ /**
247
+ * Add member to a sorted set with score (ZADD)
248
+ * @returns number of elements added (0 if already exists, updates score)
249
+ */
250
+ export async function redisZAdd(key, score, member) {
251
+ const redis = getRedisClient();
252
+ return redis.zadd(key, score, member);
253
+ }
254
+ /**
255
+ * Remove member from a sorted set (ZREM)
256
+ * @returns number of elements removed
257
+ */
258
+ export async function redisZRem(key, member) {
259
+ const redis = getRedisClient();
260
+ return redis.zrem(key, member);
261
+ }
262
+ /**
263
+ * Get members from sorted set by score range (ZRANGEBYSCORE)
264
+ * Returns members with lowest scores first (highest priority)
265
+ * @param min - Minimum score (use '-inf' for no minimum)
266
+ * @param max - Maximum score (use '+inf' for no maximum)
267
+ * @param limit - Maximum number of results
268
+ */
269
+ export async function redisZRangeByScore(key, min, max, limit) {
270
+ const redis = getRedisClient();
271
+ if (limit !== undefined) {
272
+ return redis.zrangebyscore(key, min, max, 'LIMIT', 0, limit);
273
+ }
274
+ return redis.zrangebyscore(key, min, max);
275
+ }
276
+ /**
277
+ * Get the number of members in a sorted set (ZCARD)
278
+ */
279
+ export async function redisZCard(key) {
280
+ const redis = getRedisClient();
281
+ return redis.zcard(key);
282
+ }
283
+ /**
284
+ * Pop the member with the lowest score (ZPOPMIN)
285
+ * @returns [member, score] or null if set is empty
286
+ */
287
+ export async function redisZPopMin(key) {
288
+ const redis = getRedisClient();
289
+ const result = await redis.zpopmin(key);
290
+ if (result && result.length >= 2) {
291
+ return { member: result[0], score: parseFloat(result[1]) };
292
+ }
293
+ return null;
294
+ }
295
+ // ============================================
296
+ // Hash Operations (for work item lookup)
297
+ // ============================================
298
+ /**
299
+ * Set a field in a hash (HSET)
300
+ * @returns 1 if field is new, 0 if field existed
301
+ */
302
+ export async function redisHSet(key, field, value) {
303
+ const redis = getRedisClient();
304
+ return redis.hset(key, field, value);
305
+ }
306
+ /**
307
+ * Get a field from a hash (HGET)
308
+ */
309
+ export async function redisHGet(key, field) {
310
+ const redis = getRedisClient();
311
+ return redis.hget(key, field);
312
+ }
313
+ /**
314
+ * Delete a field from a hash (HDEL)
315
+ * @returns number of fields removed
316
+ */
317
+ export async function redisHDel(key, field) {
318
+ const redis = getRedisClient();
319
+ return redis.hdel(key, field);
320
+ }
321
+ /**
322
+ * Get multiple fields from a hash (HMGET)
323
+ */
324
+ export async function redisHMGet(key, fields) {
325
+ const redis = getRedisClient();
326
+ if (fields.length === 0)
327
+ return [];
328
+ return redis.hmget(key, ...fields);
329
+ }
330
+ /**
331
+ * Get all fields and values from a hash (HGETALL)
332
+ */
333
+ export async function redisHGetAll(key) {
334
+ const redis = getRedisClient();
335
+ return redis.hgetall(key);
336
+ }
337
+ /**
338
+ * Get the number of fields in a hash (HLEN)
339
+ */
340
+ export async function redisHLen(key) {
341
+ const redis = getRedisClient();
342
+ return redis.hlen(key);
343
+ }
@@ -0,0 +1,144 @@
1
+ import type { AgentWorkType } from './types';
2
+ /**
3
+ * Agent session status
4
+ * - pending: Queued, waiting for a worker to claim
5
+ * - claimed: Worker has claimed but not yet started
6
+ * - running: Agent is actively processing
7
+ * - finalizing: Agent work done, cleanup in progress (worktree removal, orchestrator teardown)
8
+ * - completed: Agent finished successfully (all cleanup done)
9
+ * - failed: Agent encountered an error
10
+ * - stopped: Agent was stopped by user
11
+ */
12
+ export type AgentSessionStatus = 'pending' | 'claimed' | 'running' | 'finalizing' | 'completed' | 'failed' | 'stopped';
13
+ /**
14
+ * Agent session state stored in Redis for distributed access
15
+ */
16
+ export interface AgentSessionState {
17
+ /** Linear session ID (from webhook) */
18
+ linearSessionId: string;
19
+ /** Linear issue ID */
20
+ issueId: string;
21
+ /** Issue identifier (e.g., SUP-123) */
22
+ issueIdentifier?: string;
23
+ /** Claude CLI session ID for resuming with --resume */
24
+ claudeSessionId: string | null;
25
+ /** Git worktree path */
26
+ worktreePath: string;
27
+ /** Current agent status */
28
+ status: AgentSessionStatus;
29
+ /** Unix timestamp when session was created */
30
+ createdAt: number;
31
+ /** Unix timestamp of last update */
32
+ updatedAt: number;
33
+ /** Worker ID handling this session (null if pending) */
34
+ workerId?: string | null;
35
+ /** Unix timestamp when added to work queue */
36
+ queuedAt?: number | null;
37
+ /** Unix timestamp when claimed by worker */
38
+ claimedAt?: number | null;
39
+ /** Priority in queue (1-5, lower is higher priority) */
40
+ priority?: number;
41
+ /** Prompt context for the session */
42
+ promptContext?: string;
43
+ /** Linear organization ID for OAuth token lookup */
44
+ organizationId?: string;
45
+ /** Type of work: research, development, inflight, qa, acceptance, refinement (defaults to 'development') */
46
+ workType?: AgentWorkType;
47
+ /** Linear Agent ID handling this session */
48
+ agentId?: string;
49
+ /** Total cost in USD for this session */
50
+ totalCostUsd?: number;
51
+ /** Total input tokens consumed */
52
+ inputTokens?: number;
53
+ /** Total output tokens consumed */
54
+ outputTokens?: number;
55
+ }
56
+ /**
57
+ * Store agent session state in Redis
58
+ *
59
+ * @param linearSessionId - The Linear session ID from webhook
60
+ * @param state - The session state to store
61
+ */
62
+ export declare function storeSessionState(linearSessionId: string, state: Omit<AgentSessionState, 'linearSessionId' | 'createdAt' | 'updatedAt'>): Promise<AgentSessionState>;
63
+ /**
64
+ * Retrieve agent session state from Redis
65
+ *
66
+ * @param linearSessionId - The Linear session ID
67
+ * @returns The session state or null if not found
68
+ */
69
+ export declare function getSessionState(linearSessionId: string): Promise<AgentSessionState | null>;
70
+ /**
71
+ * Update the Claude session ID for a session
72
+ * Called when the Claude init event is received with the session ID
73
+ *
74
+ * @param linearSessionId - The Linear session ID
75
+ * @param claudeSessionId - The Claude CLI session ID
76
+ */
77
+ export declare function updateClaudeSessionId(linearSessionId: string, claudeSessionId: string): Promise<boolean>;
78
+ /**
79
+ * Update session status
80
+ *
81
+ * @param linearSessionId - The Linear session ID
82
+ * @param status - The new status
83
+ */
84
+ export declare function updateSessionStatus(linearSessionId: string, status: AgentSessionState['status']): Promise<boolean>;
85
+ /**
86
+ * Reset a session for re-queuing after orphan cleanup
87
+ * Clears workerId and resets status to pending so a new worker can claim it
88
+ *
89
+ * @param linearSessionId - The Linear session ID
90
+ */
91
+ export declare function resetSessionForRequeue(linearSessionId: string): Promise<boolean>;
92
+ /**
93
+ * Delete session state from KV
94
+ *
95
+ * @param linearSessionId - The Linear session ID
96
+ * @returns Whether the deletion was successful
97
+ */
98
+ export declare function deleteSessionState(linearSessionId: string): Promise<boolean>;
99
+ /**
100
+ * Get session state by issue ID
101
+ * Useful when we have the issue but not the session ID
102
+ *
103
+ * @param issueId - The Linear issue ID
104
+ * @returns The most recent session state for this issue or null
105
+ */
106
+ export declare function getSessionStateByIssue(issueId: string): Promise<AgentSessionState | null>;
107
+ /**
108
+ * Mark a session as claimed by a worker
109
+ *
110
+ * @param linearSessionId - The Linear session ID
111
+ * @param workerId - The worker claiming the session
112
+ */
113
+ export declare function claimSession(linearSessionId: string, workerId: string): Promise<boolean>;
114
+ /**
115
+ * Update session with worker info when work starts
116
+ *
117
+ * @param linearSessionId - The Linear session ID
118
+ * @param workerId - The worker processing the session
119
+ * @param worktreePath - Path to the git worktree
120
+ */
121
+ export declare function startSession(linearSessionId: string, workerId: string, worktreePath: string): Promise<boolean>;
122
+ /**
123
+ * Get all sessions from Redis
124
+ * For dashboard display
125
+ */
126
+ export declare function getAllSessions(): Promise<AgentSessionState[]>;
127
+ /**
128
+ * Get sessions by status
129
+ */
130
+ export declare function getSessionsByStatus(status: AgentSessionStatus | AgentSessionStatus[]): Promise<AgentSessionState[]>;
131
+ /**
132
+ * Transfer session ownership to a new worker
133
+ * Used when a worker re-registers after disconnection and gets a new ID
134
+ *
135
+ * @param linearSessionId - The Linear session ID
136
+ * @param newWorkerId - The new worker ID to assign
137
+ * @param oldWorkerId - The previous worker ID (for validation)
138
+ * @returns Whether the transfer was successful
139
+ */
140
+ export declare function transferSessionOwnership(linearSessionId: string, newWorkerId: string, oldWorkerId: string): Promise<{
141
+ transferred: boolean;
142
+ reason?: string;
143
+ }>;
144
+ //# sourceMappingURL=session-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-storage.d.ts","sourceRoot":"","sources":["../../src/session-storage.ts"],"names":[],"mappings":"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"}