@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.
- package/LICENSE +21 -0
- package/dist/src/agent-tracking.d.ts +98 -0
- package/dist/src/agent-tracking.d.ts.map +1 -0
- package/dist/src/agent-tracking.js +185 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +16 -0
- package/dist/src/issue-lock.d.ts +126 -0
- package/dist/src/issue-lock.d.ts.map +1 -0
- package/dist/src/issue-lock.js +499 -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-storage.d.ts +144 -0
- package/dist/src/session-storage.d.ts.map +1 -0
- package/dist/src/session-storage.js +349 -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 +119 -0
- package/dist/src/work-queue.d.ts.map +1 -0
- package/dist/src/work-queue.js +366 -0
- package/dist/src/worker-storage.d.ts +100 -0
- package/dist/src/worker-storage.d.ts.map +1 -0
- package/dist/src/worker-storage.js +283 -0
- package/package.json +61 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Manages worker registration and tracking in Redis.
|
|
5
|
+
* Workers register on startup, send periodic heartbeats,
|
|
6
|
+
* and deregister on shutdown.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { redisSet, redisGet, redisDel, redisKeys, redisSAdd, redisSRem, redisSMembers, isRedisConfigured, } from './redis';
|
|
10
|
+
const log = {
|
|
11
|
+
info: (msg, data) => console.log(`[worker] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
12
|
+
warn: (msg, data) => console.warn(`[worker] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
13
|
+
error: (msg, data) => console.error(`[worker] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
14
|
+
debug: (_msg, _data) => { },
|
|
15
|
+
};
|
|
16
|
+
// Redis key constants
|
|
17
|
+
const WORKER_PREFIX = 'work:worker:';
|
|
18
|
+
const WORKER_SESSIONS_SUFFIX = ':sessions';
|
|
19
|
+
// Default TTL for worker registration (120 seconds)
|
|
20
|
+
// Worker must send heartbeat within this time or be considered offline
|
|
21
|
+
const WORKER_TTL = parseInt(process.env.WORKER_TTL ?? '120', 10);
|
|
22
|
+
// Heartbeat timeout (90 seconds = 3 missed 30-second heartbeats)
|
|
23
|
+
const HEARTBEAT_TIMEOUT = parseInt(process.env.WORKER_HEARTBEAT_TIMEOUT ?? '90000', 10);
|
|
24
|
+
// Configurable intervals (in milliseconds)
|
|
25
|
+
const HEARTBEAT_INTERVAL = parseInt(process.env.WORKER_HEARTBEAT_INTERVAL ?? '30000', 10);
|
|
26
|
+
const POLL_INTERVAL = parseInt(process.env.WORKER_POLL_INTERVAL ?? '5000', 10);
|
|
27
|
+
/**
|
|
28
|
+
* Register a new worker
|
|
29
|
+
*
|
|
30
|
+
* @param hostname - Worker's hostname
|
|
31
|
+
* @param capacity - Maximum concurrent agents the worker can handle
|
|
32
|
+
* @param version - Optional worker software version
|
|
33
|
+
* @returns Worker ID and configuration
|
|
34
|
+
*/
|
|
35
|
+
export async function registerWorker(hostname, capacity, version) {
|
|
36
|
+
if (!isRedisConfigured()) {
|
|
37
|
+
log.warn('Redis not configured, cannot register worker');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const workerId = `wkr_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const workerData = {
|
|
44
|
+
id: workerId,
|
|
45
|
+
hostname,
|
|
46
|
+
capacity,
|
|
47
|
+
activeCount: 0,
|
|
48
|
+
registeredAt: now,
|
|
49
|
+
lastHeartbeat: now,
|
|
50
|
+
status: 'active',
|
|
51
|
+
version,
|
|
52
|
+
};
|
|
53
|
+
const key = `${WORKER_PREFIX}${workerId}`;
|
|
54
|
+
await redisSet(key, workerData, WORKER_TTL);
|
|
55
|
+
log.info('Worker registered', {
|
|
56
|
+
workerId,
|
|
57
|
+
hostname,
|
|
58
|
+
capacity,
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
workerId,
|
|
62
|
+
heartbeatInterval: HEARTBEAT_INTERVAL,
|
|
63
|
+
pollInterval: POLL_INTERVAL,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
log.error('Failed to register worker', { error, hostname });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Update worker heartbeat
|
|
73
|
+
*
|
|
74
|
+
* @param workerId - Worker ID
|
|
75
|
+
* @param activeCount - Current number of active agents
|
|
76
|
+
* @param load - Optional system load metrics
|
|
77
|
+
* @returns Heartbeat acknowledgment or null on failure
|
|
78
|
+
*/
|
|
79
|
+
export async function updateHeartbeat(workerId, activeCount, load) {
|
|
80
|
+
if (!isRedisConfigured()) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const key = `${WORKER_PREFIX}${workerId}`;
|
|
85
|
+
const worker = await redisGet(key);
|
|
86
|
+
if (!worker) {
|
|
87
|
+
log.warn('Heartbeat for unknown worker', { workerId });
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// Update worker data
|
|
91
|
+
const updatedWorker = {
|
|
92
|
+
...worker,
|
|
93
|
+
activeCount,
|
|
94
|
+
lastHeartbeat: Date.now(),
|
|
95
|
+
status: 'active',
|
|
96
|
+
};
|
|
97
|
+
// Reset TTL on heartbeat
|
|
98
|
+
await redisSet(key, updatedWorker, WORKER_TTL);
|
|
99
|
+
// Get pending work count (import dynamically to avoid circular dep)
|
|
100
|
+
const { getQueueLength } = await import('./work-queue');
|
|
101
|
+
const pendingWorkCount = await getQueueLength();
|
|
102
|
+
return {
|
|
103
|
+
acknowledged: true,
|
|
104
|
+
serverTime: new Date().toISOString(),
|
|
105
|
+
pendingWorkCount,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
log.error('Failed to update heartbeat', { error, workerId });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get worker by ID
|
|
115
|
+
*/
|
|
116
|
+
export async function getWorker(workerId) {
|
|
117
|
+
if (!isRedisConfigured()) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const key = `${WORKER_PREFIX}${workerId}`;
|
|
122
|
+
const worker = await redisGet(key);
|
|
123
|
+
if (!worker) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// Get active sessions for this worker
|
|
127
|
+
const sessionsKey = `${key}${WORKER_SESSIONS_SUFFIX}`;
|
|
128
|
+
const activeSessions = await redisSMembers(sessionsKey);
|
|
129
|
+
return {
|
|
130
|
+
...worker,
|
|
131
|
+
activeSessions,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
log.error('Failed to get worker', { error, workerId });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Deregister a worker
|
|
141
|
+
*
|
|
142
|
+
* @param workerId - Worker ID to deregister
|
|
143
|
+
* @returns List of session IDs that need to be re-queued
|
|
144
|
+
*/
|
|
145
|
+
export async function deregisterWorker(workerId) {
|
|
146
|
+
if (!isRedisConfigured()) {
|
|
147
|
+
return { deregistered: false, unclaimedSessions: [] };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const key = `${WORKER_PREFIX}${workerId}`;
|
|
151
|
+
const sessionsKey = `${key}${WORKER_SESSIONS_SUFFIX}`;
|
|
152
|
+
// Get sessions that need to be re-queued
|
|
153
|
+
const unclaimedSessions = await redisSMembers(sessionsKey);
|
|
154
|
+
// Delete worker registration and sessions set
|
|
155
|
+
await redisDel(key);
|
|
156
|
+
await redisDel(sessionsKey);
|
|
157
|
+
log.info('Worker deregistered', {
|
|
158
|
+
workerId,
|
|
159
|
+
unclaimedSessions: unclaimedSessions.length,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
deregistered: true,
|
|
163
|
+
unclaimedSessions,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
log.error('Failed to deregister worker', { error, workerId });
|
|
168
|
+
return { deregistered: false, unclaimedSessions: [] };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* List all registered workers
|
|
173
|
+
*/
|
|
174
|
+
export async function listWorkers() {
|
|
175
|
+
if (!isRedisConfigured()) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const keys = await redisKeys(`${WORKER_PREFIX}*`);
|
|
180
|
+
// Filter out session keys
|
|
181
|
+
const workerKeys = keys.filter((k) => !k.endsWith(WORKER_SESSIONS_SUFFIX));
|
|
182
|
+
const workers = [];
|
|
183
|
+
for (const key of workerKeys) {
|
|
184
|
+
const worker = await redisGet(key);
|
|
185
|
+
if (worker) {
|
|
186
|
+
const sessionsKey = `${key}${WORKER_SESSIONS_SUFFIX}`;
|
|
187
|
+
const activeSessions = await redisSMembers(sessionsKey);
|
|
188
|
+
// Check if worker is stale
|
|
189
|
+
const isStale = Date.now() - worker.lastHeartbeat > HEARTBEAT_TIMEOUT;
|
|
190
|
+
const status = isStale ? 'offline' : worker.status;
|
|
191
|
+
workers.push({
|
|
192
|
+
...worker,
|
|
193
|
+
status,
|
|
194
|
+
activeSessions,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return workers;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
log.error('Failed to list workers', { error });
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Get workers that have missed heartbeats (stale workers)
|
|
207
|
+
*/
|
|
208
|
+
export async function getStaleWorkers() {
|
|
209
|
+
if (!isRedisConfigured()) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const workers = await listWorkers();
|
|
214
|
+
return workers.filter((w) => w.status === 'offline');
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
log.error('Failed to get stale workers', { error });
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Add a session to a worker's active sessions
|
|
223
|
+
*
|
|
224
|
+
* @param workerId - Worker ID
|
|
225
|
+
* @param sessionId - Session ID being processed
|
|
226
|
+
*/
|
|
227
|
+
export async function addWorkerSession(workerId, sessionId) {
|
|
228
|
+
if (!isRedisConfigured()) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const sessionsKey = `${WORKER_PREFIX}${workerId}${WORKER_SESSIONS_SUFFIX}`;
|
|
233
|
+
await redisSAdd(sessionsKey, sessionId);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
log.error('Failed to add worker session', { error, workerId, sessionId });
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Remove a session from a worker's active sessions
|
|
243
|
+
*
|
|
244
|
+
* @param workerId - Worker ID
|
|
245
|
+
* @param sessionId - Session ID to remove
|
|
246
|
+
*/
|
|
247
|
+
export async function removeWorkerSession(workerId, sessionId) {
|
|
248
|
+
if (!isRedisConfigured()) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const sessionsKey = `${WORKER_PREFIX}${workerId}${WORKER_SESSIONS_SUFFIX}`;
|
|
253
|
+
await redisSRem(sessionsKey, sessionId);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
log.error('Failed to remove worker session', { error, workerId, sessionId });
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get total capacity across all active workers
|
|
263
|
+
*/
|
|
264
|
+
export async function getTotalCapacity() {
|
|
265
|
+
if (!isRedisConfigured()) {
|
|
266
|
+
return { totalCapacity: 0, totalActive: 0, availableCapacity: 0 };
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const workers = await listWorkers();
|
|
270
|
+
const activeWorkers = workers.filter((w) => w.status === 'active');
|
|
271
|
+
const totalCapacity = activeWorkers.reduce((sum, w) => sum + w.capacity, 0);
|
|
272
|
+
const totalActive = activeWorkers.reduce((sum, w) => sum + w.activeCount, 0);
|
|
273
|
+
return {
|
|
274
|
+
totalCapacity,
|
|
275
|
+
totalActive,
|
|
276
|
+
availableCapacity: totalCapacity - totalActive,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
log.error('Failed to get total capacity', { error });
|
|
281
|
+
return { totalCapacity: 0, totalActive: 0, availableCapacity: 0 };
|
|
282
|
+
}
|
|
283
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supaku/agentfactory-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Webhook server and distributed worker pool for AgentFactory — Redis queues, issue locks, session management",
|
|
6
|
+
"author": "Supaku (https://supaku.com)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.0.0"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/supaku/agentfactory",
|
|
14
|
+
"directory": "packages/server"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/supaku/agentfactory/tree/main/packages/server",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/supaku/agentfactory/issues"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"webhook",
|
|
25
|
+
"worker-pool",
|
|
26
|
+
"redis",
|
|
27
|
+
"agent-server",
|
|
28
|
+
"distributed"
|
|
29
|
+
],
|
|
30
|
+
"main": "./dist/src/index.js",
|
|
31
|
+
"module": "./dist/src/index.js",
|
|
32
|
+
"types": "./dist/src/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/src/index.d.ts",
|
|
36
|
+
"import": "./dist/src/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"ioredis": "^5.4.2",
|
|
46
|
+
"@supaku/agentfactory": "0.1.0",
|
|
47
|
+
"@supaku/agentfactory-linear": "0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.5.4",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^3.2.3"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"clean": "rm -rf dist"
|
|
60
|
+
}
|
|
61
|
+
}
|