web-agent-bridge 2.4.0 → 2.6.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,266 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WAB Runtime - Execution Sandbox
5
+ *
6
+ * Isolates task execution. Each task runs in its own sandbox with:
7
+ * - Resource limits (memory, CPU time, network)
8
+ * - Permission boundaries
9
+ * - Isolated state
10
+ * - Audit trail
11
+ */
12
+
13
+ const crypto = require('crypto');
14
+
15
+ class ExecutionSandbox {
16
+ constructor(options = {}) {
17
+ this._sandboxes = new Map();
18
+ this._maxConcurrent = options.maxConcurrent || 100;
19
+ this._defaultTimeout = options.defaultTimeout || 30000;
20
+ this._stats = { created: 0, completed: 0, failed: 0, timedOut: 0 };
21
+ }
22
+
23
+ /**
24
+ * Create a new sandbox for a task
25
+ */
26
+ create(taskId, options = {}) {
27
+ if (this._sandboxes.size >= this._maxConcurrent) {
28
+ throw new Error('Maximum concurrent sandbox limit reached');
29
+ }
30
+
31
+ const sandbox = {
32
+ id: `sbx_${crypto.randomBytes(12).toString('hex')}`,
33
+ taskId,
34
+ agentId: options.agentId || null,
35
+ siteId: options.siteId || null,
36
+
37
+ // Resource limits
38
+ limits: {
39
+ timeout: options.timeout || this._defaultTimeout,
40
+ maxMemory: options.maxMemory || 128 * 1024 * 1024, // 128MB
41
+ maxNetworkCalls: options.maxNetworkCalls || 100,
42
+ maxDomOperations: options.maxDomOperations || 1000,
43
+ allowedDomains: options.allowedDomains || ['*'],
44
+ blockedSelectors: options.blockedSelectors || [],
45
+ },
46
+
47
+ // Runtime state
48
+ state: 'created',
49
+ usage: {
50
+ networkCalls: 0,
51
+ domOperations: 0,
52
+ startedAt: null,
53
+ completedAt: null,
54
+ },
55
+
56
+ // Permission boundaries
57
+ capabilities: new Set(options.capabilities || []),
58
+
59
+ // Audit trail
60
+ audit: [],
61
+
62
+ // Isolated key-value store
63
+ store: new Map(),
64
+
65
+ createdAt: Date.now(),
66
+ };
67
+
68
+ this._sandboxes.set(sandbox.id, sandbox);
69
+ this._stats.created++;
70
+ return sandbox;
71
+ }
72
+
73
+ /**
74
+ * Execute a function within a sandbox
75
+ */
76
+ async execute(sandboxId, fn) {
77
+ const sandbox = this._sandboxes.get(sandboxId);
78
+ if (!sandbox) throw new Error(`Sandbox not found: ${sandboxId}`);
79
+ if (sandbox.state !== 'created' && sandbox.state !== 'running') {
80
+ throw new Error(`Sandbox ${sandboxId} is in state ${sandbox.state}, cannot execute`);
81
+ }
82
+
83
+ sandbox.state = 'running';
84
+ sandbox.usage.startedAt = Date.now();
85
+
86
+ // Create scoped context for the function
87
+ const context = this._createContext(sandbox);
88
+
89
+ try {
90
+ const result = await _withTimeout(fn(context), sandbox.limits.timeout);
91
+ sandbox.state = 'completed';
92
+ sandbox.usage.completedAt = Date.now();
93
+ this._stats.completed++;
94
+
95
+ sandbox.audit.push({
96
+ action: 'complete',
97
+ timestamp: Date.now(),
98
+ duration: sandbox.usage.completedAt - sandbox.usage.startedAt,
99
+ });
100
+
101
+ return { success: true, result, sandbox: this._getSandboxSummary(sandbox) };
102
+ } catch (err) {
103
+ sandbox.state = err.message.includes('timed out') ? 'timeout' : 'failed';
104
+ sandbox.usage.completedAt = Date.now();
105
+
106
+ if (sandbox.state === 'timeout') this._stats.timedOut++;
107
+ else this._stats.failed++;
108
+
109
+ sandbox.audit.push({
110
+ action: sandbox.state,
111
+ timestamp: Date.now(),
112
+ error: err.message,
113
+ });
114
+
115
+ return { success: false, error: err.message, sandbox: this._getSandboxSummary(sandbox) };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Create a scoped execution context
121
+ */
122
+ _createContext(sandbox) {
123
+ const self = this;
124
+ return {
125
+ taskId: sandbox.taskId,
126
+ agentId: sandbox.agentId,
127
+ siteId: sandbox.siteId,
128
+
129
+ // Capability check
130
+ hasCapability(cap) {
131
+ return sandbox.capabilities.has(cap);
132
+ },
133
+
134
+ requireCapability(cap) {
135
+ if (!sandbox.capabilities.has(cap)) {
136
+ throw new Error(`Sandbox lacks capability: ${cap}`);
137
+ }
138
+ },
139
+
140
+ // Domain check
141
+ checkDomain(domain) {
142
+ if (sandbox.limits.allowedDomains[0] === '*') return true;
143
+ return sandbox.limits.allowedDomains.some(d => domain.endsWith(d));
144
+ },
145
+
146
+ // Resource tracking
147
+ trackNetworkCall() {
148
+ sandbox.usage.networkCalls++;
149
+ if (sandbox.usage.networkCalls > sandbox.limits.maxNetworkCalls) {
150
+ throw new Error('Network call limit exceeded');
151
+ }
152
+ },
153
+
154
+ trackDomOperation() {
155
+ sandbox.usage.domOperations++;
156
+ if (sandbox.usage.domOperations > sandbox.limits.maxDomOperations) {
157
+ throw new Error('DOM operation limit exceeded');
158
+ }
159
+ },
160
+
161
+ // Isolated store
162
+ set(key, value) { sandbox.store.set(key, value); },
163
+ get(key) { return sandbox.store.get(key); },
164
+
165
+ // Audit
166
+ log(action, details) {
167
+ sandbox.audit.push({ action, details, timestamp: Date.now() });
168
+ },
169
+
170
+ // Selector validation
171
+ checkSelector(selector) {
172
+ for (const blocked of sandbox.limits.blockedSelectors) {
173
+ if (selector.includes(blocked)) {
174
+ throw new Error(`Selector blocked by sandbox policy: ${blocked}`);
175
+ }
176
+ }
177
+ return true;
178
+ },
179
+
180
+ // Read sandbox time remaining
181
+ get timeRemaining() {
182
+ if (!sandbox.usage.startedAt) return sandbox.limits.timeout;
183
+ return Math.max(0, sandbox.limits.timeout - (Date.now() - sandbox.usage.startedAt));
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Get sandbox summary (safe to expose)
190
+ */
191
+ _getSandboxSummary(sandbox) {
192
+ return {
193
+ id: sandbox.id,
194
+ taskId: sandbox.taskId,
195
+ state: sandbox.state,
196
+ usage: { ...sandbox.usage },
197
+ auditCount: sandbox.audit.length,
198
+ duration: sandbox.usage.completedAt
199
+ ? sandbox.usage.completedAt - sandbox.usage.startedAt
200
+ : null,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Destroy a sandbox and free resources
206
+ */
207
+ destroy(sandboxId) {
208
+ const sandbox = this._sandboxes.get(sandboxId);
209
+ if (sandbox) {
210
+ sandbox.store.clear();
211
+ this._sandboxes.delete(sandboxId);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Get audit trail for a sandbox
217
+ */
218
+ getAudit(sandboxId) {
219
+ const sandbox = this._sandboxes.get(sandboxId);
220
+ return sandbox ? [...sandbox.audit] : [];
221
+ }
222
+
223
+ /**
224
+ * List active sandboxes
225
+ */
226
+ listActive() {
227
+ const active = [];
228
+ for (const [, sb] of this._sandboxes) {
229
+ if (sb.state === 'created' || sb.state === 'running') {
230
+ active.push(this._getSandboxSummary(sb));
231
+ }
232
+ }
233
+ return active;
234
+ }
235
+
236
+ getStats() {
237
+ return { ...this._stats, active: this._sandboxes.size };
238
+ }
239
+
240
+ /**
241
+ * Cleanup completed/failed sandboxes older than maxAge
242
+ */
243
+ cleanup(maxAge = 3600_000) {
244
+ const cutoff = Date.now() - maxAge;
245
+ let cleaned = 0;
246
+ for (const [id, sb] of this._sandboxes) {
247
+ if (sb.state !== 'created' && sb.state !== 'running' && sb.createdAt < cutoff) {
248
+ sb.store.clear();
249
+ this._sandboxes.delete(id);
250
+ cleaned++;
251
+ }
252
+ }
253
+ return cleaned;
254
+ }
255
+ }
256
+
257
+ function _withTimeout(promise, ms) {
258
+ if (!ms || ms <= 0) return promise;
259
+ return new Promise((resolve, reject) => {
260
+ const timer = setTimeout(() => reject(new Error(`Sandbox execution timed out after ${ms}ms`)), ms);
261
+ promise.then(r => { clearTimeout(timer); resolve(r); })
262
+ .catch(e => { clearTimeout(timer); reject(e); });
263
+ });
264
+ }
265
+
266
+ module.exports = { ExecutionSandbox };
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WAB Runtime - Task Scheduler
5
+ *
6
+ * Distributes and manages task execution with:
7
+ * - Priority queue
8
+ * - Retry logic with exponential backoff
9
+ * - Timeout management
10
+ * - Dependency resolution
11
+ * - Concurrency control
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+ const { bus } = require('./event-bus');
16
+
17
+ // Task states
18
+ const TaskState = {
19
+ QUEUED: 'queued',
20
+ SCHEDULED: 'scheduled',
21
+ RUNNING: 'running',
22
+ PAUSED: 'paused',
23
+ COMPLETED: 'completed',
24
+ FAILED: 'failed',
25
+ CANCELLED: 'cancelled',
26
+ RETRYING: 'retrying',
27
+ };
28
+
29
+ class Scheduler {
30
+ constructor(options = {}) {
31
+ this._queue = []; // priority queue
32
+ this._running = new Map(); // taskId → task
33
+ this._completed = new Map(); // taskId → result (limited buffer)
34
+ this._handlers = new Map(); // task type → handler function
35
+ this._maxConcurrent = options.maxConcurrent || 20;
36
+ this._maxQueueSize = options.maxQueueSize || 1000;
37
+ this._maxCompleted = options.maxCompleted || 500;
38
+ this._defaultRetries = options.defaultRetries || 3;
39
+ this._defaultTimeout = options.defaultTimeout || 30000;
40
+ this._processing = false;
41
+ this._stats = { submitted: 0, completed: 0, failed: 0, retried: 0, cancelled: 0, timedOut: 0 };
42
+ }
43
+
44
+ /**
45
+ * Register a handler for a task type
46
+ */
47
+ registerHandler(taskType, handler) {
48
+ this._handlers.set(taskType, handler);
49
+ }
50
+
51
+ /**
52
+ * Submit a task to the scheduler
53
+ */
54
+ submit(task) {
55
+ if (this._queue.length >= this._maxQueueSize) {
56
+ throw new Error('Task queue is full');
57
+ }
58
+
59
+ const taskEntry = {
60
+ id: task.id || `task_${crypto.randomBytes(16).toString('hex')}`,
61
+ type: task.type || 'general',
62
+ objective: task.objective || '',
63
+ params: task.params || {},
64
+ steps: task.steps || [],
65
+ priority: task.priority || 50,
66
+ agentId: task.agentId || null,
67
+ siteId: task.siteId || null,
68
+ traceId: task.traceId || `trace_${crypto.randomBytes(16).toString('hex')}`,
69
+
70
+ // Execution config
71
+ retries: task.retries !== undefined ? task.retries : this._defaultRetries,
72
+ retriesLeft: task.retries !== undefined ? task.retries : this._defaultRetries,
73
+ timeout: task.timeout || this._defaultTimeout,
74
+ deadline: task.deadline || null,
75
+
76
+ // Dependencies
77
+ dependsOn: task.dependsOn || [],
78
+
79
+ // State
80
+ state: TaskState.QUEUED,
81
+ attempt: 0,
82
+ currentStep: 0,
83
+ result: null,
84
+ error: null,
85
+ progress: 0,
86
+
87
+ // Timestamps
88
+ submittedAt: Date.now(),
89
+ scheduledAt: null,
90
+ startedAt: null,
91
+ completedAt: null,
92
+
93
+ // Checkpoints (for resume/rollback)
94
+ checkpoints: [],
95
+ };
96
+
97
+ // Insert by priority (higher priority = earlier in queue)
98
+ let inserted = false;
99
+ for (let i = 0; i < this._queue.length; i++) {
100
+ if (taskEntry.priority > this._queue[i].priority) {
101
+ this._queue.splice(i, 0, taskEntry);
102
+ inserted = true;
103
+ break;
104
+ }
105
+ }
106
+ if (!inserted) this._queue.push(taskEntry);
107
+
108
+ this._stats.submitted++;
109
+ bus.emit('task.queued', { taskId: taskEntry.id, type: taskEntry.type, priority: taskEntry.priority });
110
+
111
+ // Try to process immediately
112
+ this._processQueue();
113
+
114
+ return {
115
+ taskId: taskEntry.id,
116
+ status: taskEntry.state,
117
+ position: this._queue.indexOf(taskEntry),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Process the task queue
123
+ */
124
+ async _processQueue() {
125
+ if (this._processing) return;
126
+ this._processing = true;
127
+
128
+ while (this._queue.length > 0 && this._running.size < this._maxConcurrent) {
129
+ const task = this._findNextReady();
130
+ if (!task) break;
131
+
132
+ // Remove from queue
133
+ const idx = this._queue.indexOf(task);
134
+ if (idx !== -1) this._queue.splice(idx, 1);
135
+
136
+ // Check deadline
137
+ if (task.deadline && Date.now() > task.deadline) {
138
+ task.state = TaskState.CANCELLED;
139
+ task.error = { code: 'DEADLINE_EXCEEDED', message: 'Task deadline has passed' };
140
+ bus.emit('task.cancelled', { taskId: task.id, reason: 'deadline' });
141
+ continue;
142
+ }
143
+
144
+ // Execute
145
+ this._running.set(task.id, task);
146
+ this._executeTask(task); // fire and forget
147
+ }
148
+
149
+ this._processing = false;
150
+ }
151
+
152
+ /**
153
+ * Find next task whose dependencies are satisfied
154
+ */
155
+ _findNextReady() {
156
+ for (const task of this._queue) {
157
+ if (task.dependsOn.length === 0) return task;
158
+ const allDone = task.dependsOn.every(depId => {
159
+ const dep = this._completed.get(depId);
160
+ return dep && dep.state === TaskState.COMPLETED;
161
+ });
162
+ if (allDone) return task;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Execute a single task
169
+ */
170
+ async _executeTask(task) {
171
+ task.state = TaskState.RUNNING;
172
+ task.startedAt = Date.now();
173
+ task.attempt++;
174
+
175
+ bus.emit('task.started', { taskId: task.id, attempt: task.attempt, type: task.type });
176
+
177
+ const handler = this._handlers.get(task.type);
178
+ if (!handler) {
179
+ task.state = TaskState.FAILED;
180
+ task.error = { code: 'NO_HANDLER', message: `No handler for task type: ${task.type}` };
181
+ this._finishTask(task);
182
+ return;
183
+ }
184
+
185
+ try {
186
+ const result = await _withTimeout(
187
+ handler(task.params, {
188
+ taskId: task.id,
189
+ agentId: task.agentId,
190
+ siteId: task.siteId,
191
+ traceId: task.traceId,
192
+ steps: task.steps,
193
+ attempt: task.attempt,
194
+ reportProgress: (pct, step) => {
195
+ task.progress = pct;
196
+ if (step !== undefined) task.currentStep = step;
197
+ bus.emit('task.progress', { taskId: task.id, progress: pct, step });
198
+ },
199
+ checkpoint: (data) => {
200
+ task.checkpoints.push({ data, timestamp: Date.now(), step: task.currentStep });
201
+ },
202
+ }),
203
+ task.timeout
204
+ );
205
+
206
+ task.state = TaskState.COMPLETED;
207
+ task.result = result;
208
+ task.progress = 100;
209
+ this._stats.completed++;
210
+ bus.emit('task.completed', { taskId: task.id, duration: Date.now() - task.startedAt });
211
+ } catch (err) {
212
+ const isTimeout = err.message.includes('timed out');
213
+ if (isTimeout) this._stats.timedOut++;
214
+
215
+ if (task.retriesLeft > 0 && !isTimeout) {
216
+ // Retry with exponential backoff
217
+ task.retriesLeft--;
218
+ task.state = TaskState.RETRYING;
219
+ this._stats.retried++;
220
+ const backoff = Math.min(1000 * Math.pow(2, task.attempt - 1), 30000);
221
+ bus.emit('task.retrying', { taskId: task.id, attempt: task.attempt, backoff });
222
+
223
+ setTimeout(() => {
224
+ this._running.delete(task.id);
225
+ this._queue.unshift(task); // Add back to front of queue
226
+ this._processQueue();
227
+ }, backoff);
228
+ return;
229
+ }
230
+
231
+ task.state = TaskState.FAILED;
232
+ task.error = { code: isTimeout ? 'TIMEOUT' : 'EXECUTION_ERROR', message: err.message };
233
+ this._stats.failed++;
234
+ bus.emit('task.failed', { taskId: task.id, error: err.message, attempts: task.attempt });
235
+ }
236
+
237
+ this._finishTask(task);
238
+ }
239
+
240
+ /**
241
+ * Finish a task (move to completed buffer)
242
+ */
243
+ _finishTask(task) {
244
+ task.completedAt = Date.now();
245
+ this._running.delete(task.id);
246
+
247
+ // Store in completed buffer
248
+ this._completed.set(task.id, task);
249
+ if (this._completed.size > this._maxCompleted) {
250
+ const oldest = this._completed.keys().next().value;
251
+ this._completed.delete(oldest);
252
+ }
253
+
254
+ // Process more tasks
255
+ this._processQueue();
256
+ }
257
+
258
+ /**
259
+ * Get task status
260
+ */
261
+ getTask(taskId) {
262
+ // Check running
263
+ let task = this._running.get(taskId);
264
+ if (!task) {
265
+ // Check queue
266
+ task = this._queue.find(t => t.id === taskId);
267
+ }
268
+ if (!task) {
269
+ // Check completed
270
+ task = this._completed.get(taskId);
271
+ }
272
+ if (!task) return null;
273
+
274
+ return {
275
+ id: task.id,
276
+ type: task.type,
277
+ objective: task.objective,
278
+ state: task.state,
279
+ progress: task.progress,
280
+ currentStep: task.currentStep,
281
+ totalSteps: task.steps.length,
282
+ attempt: task.attempt,
283
+ result: task.result,
284
+ error: task.error,
285
+ checkpoints: task.checkpoints.length,
286
+ submittedAt: task.submittedAt,
287
+ startedAt: task.startedAt,
288
+ completedAt: task.completedAt,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Cancel a task
294
+ */
295
+ cancel(taskId) {
296
+ // Remove from queue
297
+ const idx = this._queue.findIndex(t => t.id === taskId);
298
+ if (idx !== -1) {
299
+ const task = this._queue.splice(idx, 1)[0];
300
+ task.state = TaskState.CANCELLED;
301
+ task.completedAt = Date.now();
302
+ this._completed.set(task.id, task);
303
+ this._stats.cancelled++;
304
+ bus.emit('task.cancelled', { taskId, reason: 'user' });
305
+ return true;
306
+ }
307
+
308
+ // Mark running task as cancelled (handler must check)
309
+ const running = this._running.get(taskId);
310
+ if (running) {
311
+ running.state = TaskState.CANCELLED;
312
+ this._stats.cancelled++;
313
+ bus.emit('task.cancelled', { taskId, reason: 'user' });
314
+ return true;
315
+ }
316
+
317
+ return false;
318
+ }
319
+
320
+ /**
321
+ * Pause a running task (handler must support this)
322
+ */
323
+ pause(taskId) {
324
+ const task = this._running.get(taskId);
325
+ if (task && task.state === TaskState.RUNNING) {
326
+ task.state = TaskState.PAUSED;
327
+ bus.emit('task.paused', { taskId });
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+
333
+ /**
334
+ * Resume a paused task
335
+ */
336
+ resume(taskId) {
337
+ const task = this._running.get(taskId);
338
+ if (task && task.state === TaskState.PAUSED) {
339
+ task.state = TaskState.RUNNING;
340
+ bus.emit('task.resumed', { taskId });
341
+ return true;
342
+ }
343
+ return false;
344
+ }
345
+
346
+ /**
347
+ * Get scheduler stats
348
+ */
349
+ getStats() {
350
+ return {
351
+ ...this._stats,
352
+ queueSize: this._queue.length,
353
+ runningCount: this._running.size,
354
+ completedBufferSize: this._completed.size,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * List tasks by state
360
+ */
361
+ listTasks(state, limit = 50) {
362
+ const tasks = [];
363
+
364
+ if (!state || state === TaskState.QUEUED) {
365
+ for (const t of this._queue.slice(0, limit)) {
366
+ tasks.push(this.getTask(t.id));
367
+ }
368
+ }
369
+ if (!state || state === TaskState.RUNNING) {
370
+ for (const [, t] of this._running) {
371
+ if (tasks.length >= limit) break;
372
+ tasks.push(this.getTask(t.id));
373
+ }
374
+ }
375
+ if (!state || [TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED].includes(state)) {
376
+ for (const [, t] of this._completed) {
377
+ if (tasks.length >= limit) break;
378
+ if (!state || t.state === state) tasks.push(this.getTask(t.id));
379
+ }
380
+ }
381
+
382
+ return tasks.slice(0, limit);
383
+ }
384
+ }
385
+
386
+ function _withTimeout(promise, ms) {
387
+ if (!ms || ms <= 0) return promise;
388
+ return new Promise((resolve, reject) => {
389
+ const timer = setTimeout(() => reject(new Error(`Task timed out after ${ms}ms`)), ms);
390
+ promise.then(r => { clearTimeout(timer); resolve(r); })
391
+ .catch(e => { clearTimeout(timer); reject(e); });
392
+ });
393
+ }
394
+
395
+ module.exports = { Scheduler, TaskState };