web-agent-bridge 2.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-agent-bridge",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "description": "Open-source middleware that bridges AI agents and websites — providing a standardized command interface for intelligent automation",
5
5
  "main": "server/index.js",
6
6
  "bin": {
package/sdk/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-agent-bridge-sdk",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "description": "SDK for building AI agents that interact with Web Agent Bridge (WAB)",
5
5
  "main": "index.js",
6
6
  "license": "MIT",
package/server/index.js CHANGED
@@ -15,6 +15,7 @@ const { maybeBootstrapAdmin, db } = require('./models/db');
15
15
  const { initSearchEngine, search, getSuggestions, getTrendingSearches, getSearchStats, purgeOldCache } = require('./services/search-engine');
16
16
  const { processMessage: agentChat } = require('./services/agent-chat');
17
17
  const agentTasks = require('./services/agent-tasks');
18
+ const { cluster } = require('./services/cluster');
18
19
 
19
20
  const authRoutes = require('./routes/auth');
20
21
  const apiRoutes = require('./routes/api');
@@ -359,6 +360,9 @@ if (process.env.NODE_ENV !== 'test') {
359
360
  // Start Agent OS runtime
360
361
  runtime.start();
361
362
 
363
+ // Start Cluster Orchestrator
364
+ cluster.start();
365
+
362
366
  server.listen(PORT, () => {
363
367
  console.log(`\n ╔══════════════════════════════════════════╗`);
364
368
  console.log(` ║ Web Agent Bridge v${pkg.version} ║`);
@@ -36,6 +36,7 @@ const { hostedRuntime } = require('../services/hosted-runtime');
36
36
  const { sessionEngine } = require('../runtime/session-engine');
37
37
  const vision = require('../services/vision');
38
38
  const { lfdEngine } = require('../services/lfd');
39
+ const { cluster, distributor } = require('../services/cluster');
39
40
 
40
41
  // ═══════════════════════════════════════════════════════════════════════════
41
42
  // AUTH MIDDLEWARE
@@ -60,9 +61,7 @@ const PUBLIC_PATHS = [
60
61
  '/recipes',
61
62
  '/vision/models',
62
63
  '/vision/extraction-script',
63
- '/recipes',
64
- '/vision/models',
65
- '/vision/extraction-script',
64
+ '/cluster/status',
66
65
  ];
67
66
 
68
67
  function authMiddleware(req, res, next) {
@@ -543,6 +542,7 @@ router.get('/observability/health', (req, res) => {
543
542
  health.hostedRuntime = hostedRuntime.getStats();
544
543
  health.metering = metering.getStats();
545
544
  health.lfd = lfdEngine.getStats();
545
+ health.cluster = cluster.getClusterStatus();
546
546
  res.json(health);
547
547
  });
548
548
 
@@ -1746,4 +1746,402 @@ router.get('/lfd/stats', (req, res) => {
1746
1746
  res.json(lfdEngine.getStats());
1747
1747
  });
1748
1748
 
1749
+ // ═══════════════════════════════════════════════════════════════════════════
1750
+ // CLUSTER — DISTRIBUTED EXECUTION & WORKER NODES
1751
+ // ═══════════════════════════════════════════════════════════════════════════
1752
+
1753
+ /**
1754
+ * Get cluster status (public)
1755
+ */
1756
+ router.get('/cluster/status', (req, res) => {
1757
+ res.json(cluster.getClusterStatus());
1758
+ });
1759
+
1760
+ /**
1761
+ * Register a worker node
1762
+ */
1763
+ router.post('/cluster/nodes', (req, res) => {
1764
+ try {
1765
+ const result = cluster.registerNode({
1766
+ name: req.body.name,
1767
+ endpoint: req.body.endpoint,
1768
+ region: req.body.region,
1769
+ zone: req.body.zone,
1770
+ role: req.body.role,
1771
+ capacity: req.body.capacity,
1772
+ tags: req.body.tags,
1773
+ hardware: req.body.hardware,
1774
+ version: req.body.version,
1775
+ secret: req.body.secret,
1776
+ });
1777
+ res.json(result);
1778
+ } catch (err) {
1779
+ res.status(400).json({ error: err.message });
1780
+ }
1781
+ });
1782
+
1783
+ /**
1784
+ * List cluster nodes
1785
+ */
1786
+ router.get('/cluster/nodes', (req, res) => {
1787
+ const nodes = cluster.listNodes({
1788
+ region: req.query.region,
1789
+ active: req.query.active === 'true',
1790
+ });
1791
+ res.json({ nodes });
1792
+ });
1793
+
1794
+ /**
1795
+ * Get a specific node
1796
+ */
1797
+ router.get('/cluster/nodes/:nodeId', (req, res) => {
1798
+ const node = cluster.getNode(req.params.nodeId);
1799
+ if (!node) return res.status(404).json({ error: 'Node not found' });
1800
+ res.json(node);
1801
+ });
1802
+
1803
+ /**
1804
+ * Remove a node
1805
+ */
1806
+ router.delete('/cluster/nodes/:nodeId', (req, res) => {
1807
+ const result = cluster.deregisterNode(req.params.nodeId);
1808
+ if (!result) return res.status(404).json({ error: 'Node not found' });
1809
+ res.json(result);
1810
+ });
1811
+
1812
+ /**
1813
+ * Worker heartbeat
1814
+ */
1815
+ router.post('/cluster/nodes/:nodeId/heartbeat', (req, res) => {
1816
+ const result = cluster.heartbeat(req.params.nodeId, {
1817
+ capacityUsed: req.body.capacityUsed,
1818
+ capacityTotal: req.body.capacityTotal,
1819
+ hardware: req.body.hardware,
1820
+ tags: req.body.tags,
1821
+ version: req.body.version,
1822
+ });
1823
+ if (!result) return res.status(404).json({ error: 'Node not found' });
1824
+ res.json(result);
1825
+ });
1826
+
1827
+ /**
1828
+ * Drain a node (stop new tasks, wait for running)
1829
+ */
1830
+ router.post('/cluster/nodes/:nodeId/drain', (req, res) => {
1831
+ const result = cluster.drainNode(req.params.nodeId);
1832
+ if (!result) return res.status(404).json({ error: 'Node not found' });
1833
+ res.json(result);
1834
+ });
1835
+
1836
+ /**
1837
+ * Cordon a node (prevent scheduling)
1838
+ */
1839
+ router.post('/cluster/nodes/:nodeId/cordon', (req, res) => {
1840
+ const result = cluster.cordonNode(req.params.nodeId);
1841
+ if (!result) return res.status(404).json({ error: 'Node not found' });
1842
+ res.json(result);
1843
+ });
1844
+
1845
+ /**
1846
+ * Uncordon a node (allow scheduling again)
1847
+ */
1848
+ router.post('/cluster/nodes/:nodeId/uncordon', (req, res) => {
1849
+ const result = cluster.uncordonNode(req.params.nodeId);
1850
+ if (!result) return res.status(404).json({ error: 'Node not found' });
1851
+ res.json(result);
1852
+ });
1853
+
1854
+ /**
1855
+ * Submit a task for distributed execution
1856
+ */
1857
+ router.post('/cluster/tasks', (req, res) => {
1858
+ try {
1859
+ const result = distributor.submit({
1860
+ type: req.body.type,
1861
+ objective: req.body.objective,
1862
+ params: req.body.params,
1863
+ priority: req.body.priority,
1864
+ affinityTags: req.body.affinityTags,
1865
+ affinityRegion: req.body.affinityRegion,
1866
+ timeout: req.body.timeout,
1867
+ maxAttempts: req.body.maxAttempts,
1868
+ externalId: req.body.externalId,
1869
+ });
1870
+ res.json(result);
1871
+ } catch (err) {
1872
+ res.status(400).json({ error: err.message });
1873
+ }
1874
+ });
1875
+
1876
+ /**
1877
+ * Get task details
1878
+ */
1879
+ router.get('/cluster/tasks/:taskId', (req, res) => {
1880
+ const task = cluster.getTask(req.params.taskId);
1881
+ if (!task) return res.status(404).json({ error: 'Task not found' });
1882
+ res.json(task);
1883
+ });
1884
+
1885
+ /**
1886
+ * List tasks
1887
+ */
1888
+ router.get('/cluster/tasks', (req, res) => {
1889
+ const tasks = cluster.listTasks({
1890
+ status: req.query.status,
1891
+ nodeId: req.query.nodeId,
1892
+ limit: parseInt(req.query.limit) || 50,
1893
+ });
1894
+ res.json({ tasks });
1895
+ });
1896
+
1897
+ /**
1898
+ * Worker pulls tasks (poll-based)
1899
+ */
1900
+ router.post('/cluster/nodes/:nodeId/pull', (req, res) => {
1901
+ const tasks = distributor.pullTasks(req.params.nodeId, parseInt(req.body.limit) || 5);
1902
+ res.json({ tasks });
1903
+ });
1904
+
1905
+ /**
1906
+ * Worker reports task started
1907
+ */
1908
+ router.post('/cluster/tasks/:taskId/started', (req, res) => {
1909
+ const result = cluster.reportTaskStarted(req.params.taskId);
1910
+ if (!result) return res.status(404).json({ error: 'Task not found' });
1911
+ res.json(result);
1912
+ });
1913
+
1914
+ /**
1915
+ * Worker reports task completed
1916
+ */
1917
+ router.post('/cluster/tasks/:taskId/completed', (req, res) => {
1918
+ const result = cluster.reportTaskCompleted(req.params.taskId, req.body.result);
1919
+ if (!result) return res.status(404).json({ error: 'Task not found' });
1920
+ res.json(result);
1921
+ });
1922
+
1923
+ /**
1924
+ * Worker reports task failed
1925
+ */
1926
+ router.post('/cluster/tasks/:taskId/failed', (req, res) => {
1927
+ const result = cluster.reportTaskFailed(req.params.taskId, req.body.error);
1928
+ if (!result) return res.status(404).json({ error: 'Task not found' });
1929
+ res.json(result);
1930
+ });
1931
+
1932
+ /**
1933
+ * Get cluster events log
1934
+ */
1935
+ router.get('/cluster/events', (req, res) => {
1936
+ const events = cluster.getEvents(
1937
+ parseInt(req.query.limit) || 100,
1938
+ req.query.nodeId || null
1939
+ );
1940
+ res.json({ events });
1941
+ });
1942
+
1943
+ // ═══════════════════════════════════════════════════════════════════════════
1944
+ // CONTAINER ISOLATION
1945
+ // ═══════════════════════════════════════════════════════════════════════════
1946
+
1947
+ let containerRunner;
1948
+ try { containerRunner = require('../runtime/container').containerRunner; } catch {}
1949
+
1950
+ /**
1951
+ * Run a task in an isolated container
1952
+ */
1953
+ router.post('/containers/run', async (req, res) => {
1954
+ if (!containerRunner) return res.status(501).json({ error: 'Container isolation not available' });
1955
+ try {
1956
+ const result = await containerRunner.runInProcess(
1957
+ req.body.taskId || `ctr_task_${Date.now()}`,
1958
+ req.body.code || '',
1959
+ {
1960
+ params: req.body.params || {},
1961
+ timeout: req.body.timeout || 60000,
1962
+ maxMemory: req.body.maxMemory || 256 * 1024 * 1024,
1963
+ allowNetwork: req.body.allowNetwork !== false,
1964
+ }
1965
+ );
1966
+ res.json(result);
1967
+ } catch (err) {
1968
+ res.status(500).json({ error: err.message });
1969
+ }
1970
+ });
1971
+
1972
+ /**
1973
+ * List active containers
1974
+ */
1975
+ router.get('/containers', (req, res) => {
1976
+ if (!containerRunner) return res.json({ containers: [] });
1977
+ res.json({ containers: containerRunner.listContainers() });
1978
+ });
1979
+
1980
+ /**
1981
+ * Get container details
1982
+ */
1983
+ router.get('/containers/:containerId', (req, res) => {
1984
+ if (!containerRunner) return res.status(404).json({ error: 'Not found' });
1985
+ const c = containerRunner.getContainer(req.params.containerId);
1986
+ if (!c) return res.status(404).json({ error: 'Container not found' });
1987
+ res.json(c);
1988
+ });
1989
+
1990
+ /**
1991
+ * Kill a container
1992
+ */
1993
+ router.post('/containers/:containerId/kill', (req, res) => {
1994
+ if (!containerRunner) return res.status(404).json({ error: 'Not found' });
1995
+ const ok = containerRunner.kill(req.params.containerId);
1996
+ res.json({ success: ok });
1997
+ });
1998
+
1999
+ /**
2000
+ * Container stats
2001
+ */
2002
+ router.get('/containers/stats/summary', (req, res) => {
2003
+ if (!containerRunner) return res.json({ active: 0 });
2004
+ res.json(containerRunner.getStats());
2005
+ });
2006
+
2007
+ /**
2008
+ * Check Docker availability
2009
+ */
2010
+ router.get('/containers/docker/status', (req, res) => {
2011
+ if (!containerRunner) return res.json({ available: false });
2012
+ res.json({ available: containerRunner.isDockerAvailable() });
2013
+ });
2014
+
2015
+ // ═══════════════════════════════════════════════════════════════════════════
2016
+ // EXTERNAL QUEUE MANAGEMENT
2017
+ // ═══════════════════════════════════════════════════════════════════════════
2018
+
2019
+ let queueModule;
2020
+ try { queueModule = require('../runtime/queue'); } catch {}
2021
+
2022
+ /**
2023
+ * Queue stats
2024
+ */
2025
+ router.get('/queue/stats', (req, res) => {
2026
+ if (!queueModule) return res.json({ backend: 'memory' });
2027
+ const q = queueModule.createQueue('scheduler');
2028
+ res.json(q.getStats());
2029
+ });
2030
+
2031
+ /**
2032
+ * Purge completed items from queue
2033
+ */
2034
+ router.post('/queue/purge', (req, res) => {
2035
+ if (!queueModule) return res.json({ purged: 0 });
2036
+ const q = queueModule.createQueue('scheduler');
2037
+ const purged = q.purgeCompleted(parseInt(req.body.maxAge) || 3600_000);
2038
+ res.json({ purged });
2039
+ });
2040
+
2041
+ // ═══════════════════════════════════════════════════════════════════════════
2042
+ // ENHANCED REPLAY
2043
+ // ═══════════════════════════════════════════════════════════════════════════
2044
+
2045
+ /**
2046
+ * Export a recording (full data for download)
2047
+ */
2048
+ router.get('/replay/recordings/:taskId/export', (req, res) => {
2049
+ const data = replayEngine.exportRecording(req.params.taskId);
2050
+ if (!data) return res.status(404).json({ error: 'Recording not found' });
2051
+ res.json(data);
2052
+ });
2053
+
2054
+ /**
2055
+ * Import a recording
2056
+ */
2057
+ router.post('/replay/recordings/import', (req, res) => {
2058
+ try {
2059
+ const id = replayEngine.importRecording(req.body);
2060
+ res.json({ success: true, recordingId: id });
2061
+ } catch (err) {
2062
+ res.status(400).json({ error: err.message });
2063
+ }
2064
+ });
2065
+
2066
+ /**
2067
+ * Delete a recording
2068
+ */
2069
+ router.delete('/replay/recordings/:taskId', (req, res) => {
2070
+ replayEngine.deleteRecording(req.params.taskId);
2071
+ res.json({ success: true });
2072
+ });
2073
+
2074
+ /**
2075
+ * Replay from a specific checkpoint
2076
+ */
2077
+ router.post('/replay/:taskId/from-checkpoint', async (req, res) => {
2078
+ try {
2079
+ const result = await replayEngine.replay(req.params.taskId, {
2080
+ verify: req.body.verify !== false,
2081
+ continueOnMismatch: !!req.body.continueOnMismatch,
2082
+ fromCheckpoint: req.body.checkpoint,
2083
+ });
2084
+ res.json(result);
2085
+ } catch (err) {
2086
+ res.status(400).json({ error: err.message });
2087
+ }
2088
+ });
2089
+
2090
+ /**
2091
+ * Purge old recordings
2092
+ */
2093
+ router.post('/replay/purge', (req, res) => {
2094
+ const maxAge = parseInt(req.body.maxAge) || 7 * 24 * 3600_000;
2095
+ replayEngine.purgeOld(maxAge);
2096
+ res.json({ success: true });
2097
+ });
2098
+
2099
+ // ═══════════════════════════════════════════════════════════════════════════
2100
+ // WORKER PULL ENDPOINT (for distributed workers)
2101
+ // ═══════════════════════════════════════════════════════════════════════════
2102
+
2103
+ /**
2104
+ * Workers pull tasks from here
2105
+ */
2106
+ router.post('/cluster/nodes/:nodeId/pull', (req, res) => {
2107
+ const limit = parseInt(req.body.limit) || 5;
2108
+ // Fetch pending tasks from the cluster task distributor
2109
+ const tasks = [];
2110
+ try {
2111
+ const pending = distributor.getPendingTasks ? distributor.getPendingTasks(req.params.nodeId, limit) : [];
2112
+ tasks.push(...pending);
2113
+ } catch {}
2114
+ res.json({ tasks });
2115
+ });
2116
+
2117
+ /**
2118
+ * Worker reports task started
2119
+ */
2120
+ router.post('/cluster/tasks/:taskId/started', (req, res) => {
2121
+ bus.emit('cluster.task.started', { taskId: req.params.taskId, nodeId: req.body.nodeId });
2122
+ res.json({ ok: true });
2123
+ });
2124
+
2125
+ /**
2126
+ * Worker reports task completed
2127
+ */
2128
+ router.post('/cluster/tasks/:taskId/completed', (req, res) => {
2129
+ bus.emit('cluster.task.completed', {
2130
+ taskId: req.params.taskId,
2131
+ result: req.body.result,
2132
+ });
2133
+ res.json({ ok: true });
2134
+ });
2135
+
2136
+ /**
2137
+ * Worker reports task failed
2138
+ */
2139
+ router.post('/cluster/tasks/:taskId/failed', (req, res) => {
2140
+ bus.emit('cluster.task.failed', {
2141
+ taskId: req.params.taskId,
2142
+ error: req.body.error,
2143
+ });
2144
+ res.json({ ok: true });
2145
+ });
2146
+
1749
2147
  module.exports = router;
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WAB Container Worker — Runs inside a forked child process
5
+ *
6
+ * This script is the entry point for process-isolated task execution.
7
+ * It reads the task definition from a JSON file, executes it,
8
+ * and sends results back via IPC.
9
+ *
10
+ * Security:
11
+ * - Runs in a separate process with memory limits (--max-old-space-size)
12
+ * - Limited filesystem access (only its tmp directory)
13
+ * - Can disable network via environment
14
+ * - Timeout enforced by parent
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const taskFile = process.argv[2];
21
+ if (!taskFile) {
22
+ process.stderr.write('No task file specified\n');
23
+ process.exit(1);
24
+ }
25
+
26
+ let taskData;
27
+ try {
28
+ taskData = JSON.parse(fs.readFileSync(taskFile, 'utf8'));
29
+ } catch (err) {
30
+ process.stderr.write(`Failed to read task file: ${err.message}\n`);
31
+ process.exit(1);
32
+ }
33
+
34
+ // ─── Sandbox Utilities (available to task code) ──────────────────────
35
+
36
+ const sandbox = {
37
+ taskId: taskData.taskId,
38
+ containerId: taskData.containerId,
39
+ params: taskData.params || {},
40
+
41
+ // Send progress updates
42
+ progress(pct) {
43
+ if (process.send) process.send({ type: 'progress', progress: pct });
44
+ },
45
+
46
+ // Send log messages
47
+ log(message) {
48
+ if (process.send) process.send({ type: 'log', message: String(message).slice(0, 1000) });
49
+ },
50
+
51
+ // Read a param
52
+ param(key, defaultValue) {
53
+ return taskData.params[key] !== undefined ? taskData.params[key] : defaultValue;
54
+ },
55
+
56
+ // Filesystem is restricted to tmpDir
57
+ tmpDir: path.dirname(taskFile),
58
+
59
+ readFile(name) {
60
+ const p = path.join(sandbox.tmpDir, path.basename(name));
61
+ return fs.readFileSync(p, 'utf8');
62
+ },
63
+
64
+ writeFile(name, content) {
65
+ const p = path.join(sandbox.tmpDir, path.basename(name));
66
+ fs.writeFileSync(p, content);
67
+ },
68
+ };
69
+
70
+ // ─── Execute Task ────────────────────────────────────────────────────
71
+
72
+ async function execute() {
73
+ try {
74
+ let result;
75
+
76
+ if (taskData.code) {
77
+ // Execute provided code string in a restricted scope
78
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
79
+ const fn = new AsyncFunction('sandbox', 'params', taskData.code);
80
+ result = await fn(sandbox, taskData.params);
81
+ } else if (taskData.module) {
82
+ // Execute a module (for trusted internal tasks)
83
+ const mod = require(taskData.module);
84
+ if (typeof mod.execute === 'function') {
85
+ result = await mod.execute(taskData.params, sandbox);
86
+ } else {
87
+ result = { error: 'Module has no execute() function' };
88
+ }
89
+ } else {
90
+ result = { echo: taskData.params, message: 'No code or module specified' };
91
+ }
92
+
93
+ // Send result back via IPC
94
+ if (process.send) {
95
+ process.send({ type: 'result', data: result });
96
+ }
97
+
98
+ // Give IPC time to flush
99
+ setTimeout(() => process.exit(0), 100);
100
+ } catch (err) {
101
+ process.stderr.write(`Task error: ${err.message}\n${err.stack}\n`);
102
+
103
+ if (process.send) {
104
+ process.send({ type: 'result', data: null });
105
+ }
106
+
107
+ setTimeout(() => process.exit(1), 100);
108
+ }
109
+ }
110
+
111
+ execute();