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