web-agent-bridge 2.9.0 → 3.0.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/package.json +1 -1
- package/sdk/package.json +1 -1
- package/server/routes/runtime.js +204 -0
- package/server/runtime/container-worker.js +111 -0
- package/server/runtime/container.js +448 -0
- package/server/runtime/distributed-worker.js +362 -0
- package/server/runtime/index.js +21 -1
- package/server/runtime/queue.js +599 -0
- package/server/runtime/replay.js +431 -29
- package/server/runtime/scheduler.js +194 -55
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Distributed Worker — Standalone Worker Process
|
|
5
|
+
*
|
|
6
|
+
* Runs as an independent process/machine that:
|
|
7
|
+
* 1. Registers with the Coordinator (WAB server)
|
|
8
|
+
* 2. Pulls tasks from the queue
|
|
9
|
+
* 3. Executes tasks in containers (process isolation)
|
|
10
|
+
* 4. Reports results back
|
|
11
|
+
* 5. Sends periodic heartbeats
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node distributed-worker.js --coordinator=https://wab.example.com --name=worker-1
|
|
15
|
+
*
|
|
16
|
+
* Or programmatically:
|
|
17
|
+
* const { DistributedWorker } = require('./distributed-worker');
|
|
18
|
+
* const worker = new DistributedWorker({ coordinatorUrl: '...', name: '...' });
|
|
19
|
+
* await worker.start();
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const https = require('https');
|
|
25
|
+
const { URL } = require('url');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const { bus } = require('./event-bus');
|
|
28
|
+
|
|
29
|
+
class DistributedWorker {
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.coordinatorUrl = (options.coordinatorUrl || process.env.WAB_COORDINATOR_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
32
|
+
this.name = options.name || `worker-${os.hostname()}-${process.pid}`;
|
|
33
|
+
this.region = options.region || process.env.WAB_REGION || 'default';
|
|
34
|
+
this.zone = options.zone || process.env.WAB_ZONE || 'a';
|
|
35
|
+
this.capacity = options.capacity || parseInt(process.env.WAB_WORKER_CAPACITY) || 10;
|
|
36
|
+
this.tags = options.tags || (process.env.WAB_WORKER_TAGS || '').split(',').filter(Boolean);
|
|
37
|
+
this.pollInterval = options.pollInterval || 5000;
|
|
38
|
+
this.heartbeatInterval = options.heartbeatInterval || 30000;
|
|
39
|
+
this.useContainers = options.useContainers !== false;
|
|
40
|
+
this.listenPort = options.listenPort || 0; // 0 = auto
|
|
41
|
+
|
|
42
|
+
this.nodeId = null;
|
|
43
|
+
this._running = new Map(); // taskId → { task, startedAt }
|
|
44
|
+
this._handlers = new Map(); // taskType → handler function
|
|
45
|
+
this._started = false;
|
|
46
|
+
this._pollTimer = null;
|
|
47
|
+
this._heartbeatTimer = null;
|
|
48
|
+
this._server = null;
|
|
49
|
+
this._stats = { executed: 0, succeeded: 0, failed: 0 };
|
|
50
|
+
this._containerRunner = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Start the worker — register, start polling, start heartbeat
|
|
57
|
+
*/
|
|
58
|
+
async start() {
|
|
59
|
+
if (this._started) return;
|
|
60
|
+
|
|
61
|
+
// Optionally load container runner
|
|
62
|
+
if (this.useContainers) {
|
|
63
|
+
try {
|
|
64
|
+
const { containerRunner } = require('./container');
|
|
65
|
+
this._containerRunner = containerRunner;
|
|
66
|
+
} catch {
|
|
67
|
+
this.useContainers = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Start push notification server
|
|
72
|
+
await this._startNotificationServer();
|
|
73
|
+
|
|
74
|
+
// Register with coordinator
|
|
75
|
+
const endpoint = `http://${this._getLocalIP()}:${this._actualPort}`;
|
|
76
|
+
const reg = await this._post('/api/os/cluster/nodes', {
|
|
77
|
+
name: this.name,
|
|
78
|
+
endpoint,
|
|
79
|
+
region: this.region,
|
|
80
|
+
zone: this.zone,
|
|
81
|
+
capacity: this.capacity,
|
|
82
|
+
tags: this.tags,
|
|
83
|
+
hardware: this._getHardware(),
|
|
84
|
+
version: require('../../package.json').version,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.nodeId = reg.nodeId;
|
|
88
|
+
this._started = true;
|
|
89
|
+
|
|
90
|
+
// Start polling for tasks
|
|
91
|
+
this._pollTimer = setInterval(() => this._poll(), this.pollInterval);
|
|
92
|
+
|
|
93
|
+
// Start heartbeat
|
|
94
|
+
this._heartbeatTimer = setInterval(() => this._heartbeat(), this.heartbeatInterval);
|
|
95
|
+
|
|
96
|
+
console.log(`[Worker] ${this.name} registered as ${this.nodeId} at ${endpoint}`);
|
|
97
|
+
console.log(`[Worker] Coordinator: ${this.coordinatorUrl}, Region: ${this.region}, Capacity: ${this.capacity}`);
|
|
98
|
+
|
|
99
|
+
bus.emit('worker.started', { nodeId: this.nodeId, name: this.name });
|
|
100
|
+
return { nodeId: this.nodeId, name: this.name };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stop the worker gracefully — finish running tasks, deregister
|
|
105
|
+
*/
|
|
106
|
+
async stop() {
|
|
107
|
+
if (!this._started) return;
|
|
108
|
+
this._started = false;
|
|
109
|
+
|
|
110
|
+
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
111
|
+
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
112
|
+
|
|
113
|
+
// Wait for running tasks to complete (with timeout)
|
|
114
|
+
if (this._running.size > 0) {
|
|
115
|
+
console.log(`[Worker] Waiting for ${this._running.size} running tasks...`);
|
|
116
|
+
const deadline = Date.now() + 30000;
|
|
117
|
+
while (this._running.size > 0 && Date.now() < deadline) {
|
|
118
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Deregister
|
|
123
|
+
if (this.nodeId) {
|
|
124
|
+
try {
|
|
125
|
+
await this._delete(`/api/os/cluster/nodes/${this.nodeId}`);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this._server) {
|
|
130
|
+
this._server.close();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log(`[Worker] ${this.name} stopped. Executed: ${this._stats.executed}`);
|
|
134
|
+
bus.emit('worker.stopped', { nodeId: this.nodeId, stats: this._stats });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Register a task type handler
|
|
139
|
+
*/
|
|
140
|
+
registerHandler(taskType, handler) {
|
|
141
|
+
this._handlers.set(taskType, handler);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Task Execution ─────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Execute a task
|
|
148
|
+
*/
|
|
149
|
+
async _executeTask(task) {
|
|
150
|
+
const taskId = task.taskId;
|
|
151
|
+
this._running.set(taskId, { task, startedAt: Date.now() });
|
|
152
|
+
this._stats.executed++;
|
|
153
|
+
|
|
154
|
+
// Report started
|
|
155
|
+
try {
|
|
156
|
+
await this._post(`/api/os/cluster/tasks/${taskId}/started`, {});
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
let result;
|
|
161
|
+
const handler = this._handlers.get(task.type);
|
|
162
|
+
|
|
163
|
+
if (handler) {
|
|
164
|
+
// Use registered handler
|
|
165
|
+
result = await handler(task.params, {
|
|
166
|
+
taskId,
|
|
167
|
+
type: task.type,
|
|
168
|
+
objective: task.objective,
|
|
169
|
+
priority: task.priority,
|
|
170
|
+
timeout: task.timeout,
|
|
171
|
+
nodeId: this.nodeId,
|
|
172
|
+
workerName: this.name,
|
|
173
|
+
});
|
|
174
|
+
} else if (this._containerRunner && typeof task.params === 'object' && task.params._code) {
|
|
175
|
+
// Execute in container isolation
|
|
176
|
+
const containerResult = await this._containerRunner.runInProcess(taskId, task.params._code, {
|
|
177
|
+
params: task.params,
|
|
178
|
+
timeout: task.timeout || 60000,
|
|
179
|
+
maxMemory: task.params._maxMemory || 256 * 1024 * 1024,
|
|
180
|
+
});
|
|
181
|
+
result = containerResult.success ? containerResult.result : { error: containerResult.error };
|
|
182
|
+
if (!containerResult.success) throw new Error(containerResult.error);
|
|
183
|
+
} else {
|
|
184
|
+
// Default: return params as acknowledgment
|
|
185
|
+
result = { received: true, type: task.type, params: task.params };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Report completed
|
|
189
|
+
await this._post(`/api/os/cluster/tasks/${taskId}/completed`, { result });
|
|
190
|
+
this._stats.succeeded++;
|
|
191
|
+
bus.emit('worker.task.completed', { taskId, nodeId: this.nodeId });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Report failed
|
|
194
|
+
try {
|
|
195
|
+
await this._post(`/api/os/cluster/tasks/${taskId}/failed`, { error: err.message });
|
|
196
|
+
} catch {}
|
|
197
|
+
this._stats.failed++;
|
|
198
|
+
bus.emit('worker.task.failed', { taskId, nodeId: this.nodeId, error: err.message });
|
|
199
|
+
} finally {
|
|
200
|
+
this._running.delete(taskId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Polling ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async _poll() {
|
|
207
|
+
if (!this._started || !this.nodeId) return;
|
|
208
|
+
|
|
209
|
+
const available = this.capacity - this._running.size;
|
|
210
|
+
if (available <= 0) return;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const response = await this._post(`/api/os/cluster/nodes/${this.nodeId}/pull`, {
|
|
214
|
+
limit: Math.min(available, 5),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (response.tasks && response.tasks.length > 0) {
|
|
218
|
+
for (const task of response.tasks) {
|
|
219
|
+
// Execute each task concurrently
|
|
220
|
+
this._executeTask(task).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
// Coordinator unreachable — will retry on next poll
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Heartbeat ──────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async _heartbeat() {
|
|
231
|
+
if (!this._started || !this.nodeId) return;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await this._post(`/api/os/cluster/nodes/${this.nodeId}/heartbeat`, {
|
|
235
|
+
capacityUsed: this._running.size,
|
|
236
|
+
capacityTotal: this.capacity,
|
|
237
|
+
hardware: this._getHardware(),
|
|
238
|
+
version: require('../../package.json').version,
|
|
239
|
+
});
|
|
240
|
+
} catch {
|
|
241
|
+
// Will retry next interval
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Push Notification Server ───────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
async _startNotificationServer() {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
this._server = http.createServer((req, res) => {
|
|
250
|
+
if (req.method === 'POST' && req.url === '/wab-worker/tasks/notify') {
|
|
251
|
+
let body = '';
|
|
252
|
+
req.on('data', d => { body += d; if (body.length > 1024 * 1024) req.destroy(); });
|
|
253
|
+
req.on('end', () => {
|
|
254
|
+
try {
|
|
255
|
+
const msg = JSON.parse(body);
|
|
256
|
+
if (msg.type === 'task.assigned' && msg.taskId) {
|
|
257
|
+
// Start executing the pushed task
|
|
258
|
+
this._executeTask({
|
|
259
|
+
taskId: msg.taskId,
|
|
260
|
+
type: msg.taskType,
|
|
261
|
+
objective: msg.objective,
|
|
262
|
+
params: msg.params,
|
|
263
|
+
priority: msg.priority,
|
|
264
|
+
timeout: msg.timeout,
|
|
265
|
+
}).catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end('{"ok":true}');
|
|
269
|
+
} catch {
|
|
270
|
+
res.writeHead(400);
|
|
271
|
+
res.end('{"error":"bad request"}');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
} else if (req.method === 'GET' && req.url === '/health') {
|
|
275
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
276
|
+
res.end(JSON.stringify({
|
|
277
|
+
nodeId: this.nodeId,
|
|
278
|
+
name: this.name,
|
|
279
|
+
running: this._running.size,
|
|
280
|
+
capacity: this.capacity,
|
|
281
|
+
stats: this._stats,
|
|
282
|
+
}));
|
|
283
|
+
} else {
|
|
284
|
+
res.writeHead(404);
|
|
285
|
+
res.end('{"error":"not found"}');
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
this._server.listen(this.listenPort, () => {
|
|
290
|
+
this._actualPort = this._server.address().port;
|
|
291
|
+
resolve();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── HTTP Helpers ───────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
_post(path, data) {
|
|
299
|
+
return this._request('POST', path, data);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_delete(path) {
|
|
303
|
+
return this._request('DELETE', path);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_request(method, urlPath, data) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const url = new URL(urlPath, this.coordinatorUrl);
|
|
309
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
310
|
+
const payload = data ? JSON.stringify(data) : null;
|
|
311
|
+
|
|
312
|
+
const req = mod.request(url, {
|
|
313
|
+
method,
|
|
314
|
+
headers: {
|
|
315
|
+
'Content-Type': 'application/json',
|
|
316
|
+
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
317
|
+
'X-WAB-Worker': this.nodeId || this.name,
|
|
318
|
+
},
|
|
319
|
+
timeout: 10000,
|
|
320
|
+
}, (res) => {
|
|
321
|
+
let body = '';
|
|
322
|
+
res.on('data', d => body += d);
|
|
323
|
+
res.on('end', () => {
|
|
324
|
+
try {
|
|
325
|
+
resolve(JSON.parse(body));
|
|
326
|
+
} catch {
|
|
327
|
+
resolve({ raw: body });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
req.on('error', reject);
|
|
333
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
334
|
+
if (payload) req.write(payload);
|
|
335
|
+
req.end();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_getLocalIP() {
|
|
340
|
+
const ifaces = os.networkInterfaces();
|
|
341
|
+
for (const name of Object.keys(ifaces)) {
|
|
342
|
+
for (const iface of ifaces[name]) {
|
|
343
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return '127.0.0.1';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_getHardware() {
|
|
350
|
+
return {
|
|
351
|
+
cpus: os.cpus().length,
|
|
352
|
+
totalMemory: os.totalmem(),
|
|
353
|
+
freeMemory: os.freemem(),
|
|
354
|
+
platform: os.platform(),
|
|
355
|
+
arch: os.arch(),
|
|
356
|
+
uptime: os.uptime(),
|
|
357
|
+
loadAvg: os.loadavg(),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = { DistributedWorker };
|
package/server/runtime/index.js
CHANGED
|
@@ -38,9 +38,24 @@ class WABRuntime {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
this.events = bus;
|
|
41
|
+
this.replay = null;
|
|
42
|
+
this.container = null;
|
|
41
43
|
this._started = false;
|
|
42
44
|
this._cleanupTimer = null;
|
|
43
45
|
|
|
46
|
+
// Attach optional services
|
|
47
|
+
try {
|
|
48
|
+
const { replayEngine } = require('./replay');
|
|
49
|
+
this.replay = replayEngine;
|
|
50
|
+
this.scheduler.setReplayEngine(replayEngine);
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const { containerRunner } = require('./container');
|
|
55
|
+
this.container = containerRunner;
|
|
56
|
+
this.scheduler.setContainerRunner(containerRunner);
|
|
57
|
+
} catch {}
|
|
58
|
+
|
|
44
59
|
// Register built-in task handlers
|
|
45
60
|
this._registerBuiltinHandlers();
|
|
46
61
|
|
|
@@ -125,10 +140,13 @@ class WABRuntime {
|
|
|
125
140
|
*/
|
|
126
141
|
getCapabilities() {
|
|
127
142
|
return {
|
|
128
|
-
scheduler: { maxConcurrent: 20, retries: true, deadlines: true, dependencies: true },
|
|
143
|
+
scheduler: { maxConcurrent: 20, retries: true, deadlines: true, dependencies: true, externalQueue: true },
|
|
129
144
|
state: { checkpoints: true, rollback: true, ttl: true },
|
|
130
145
|
sandbox: { isolation: true, resourceLimits: true, auditTrail: true },
|
|
131
146
|
events: { async: true, replay: true, wildcards: true, deadLetter: true },
|
|
147
|
+
replay: { persistent: true, eventSourcing: true, checkpoints: true, diff: true },
|
|
148
|
+
containers: { processIsolation: true, docker: !!this.container, resourceLimits: true },
|
|
149
|
+
workers: { distributed: true, pullBased: true, pushNotification: true },
|
|
132
150
|
};
|
|
133
151
|
}
|
|
134
152
|
|
|
@@ -143,6 +161,8 @@ class WABRuntime {
|
|
|
143
161
|
state: this.state.getStats(),
|
|
144
162
|
sandbox: this.sandbox.getStats(),
|
|
145
163
|
events: this.events.getStats(),
|
|
164
|
+
replay: this.replay ? this.replay.getStats() : null,
|
|
165
|
+
containers: this.container ? this.container.getStats() : null,
|
|
146
166
|
};
|
|
147
167
|
}
|
|
148
168
|
|