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.
- package/README.ar.md +18 -0
- package/README.md +32 -0
- package/package.json +1 -1
- package/public/.well-known/agent-tools.json +180 -0
- package/sdk/index.d.ts +170 -0
- package/sdk/index.js +246 -1
- package/sdk/package.json +1 -1
- package/server/adapters/index.js +520 -0
- package/server/control-plane/index.js +301 -0
- package/server/data-plane/index.js +354 -0
- package/server/index.js +6 -0
- package/server/llm/index.js +404 -0
- package/server/migrations/004_agent_os.sql +158 -0
- package/server/observability/failure-analysis.js +337 -0
- package/server/observability/index.js +394 -0
- package/server/protocol/capabilities.js +223 -0
- package/server/protocol/index.js +243 -0
- package/server/protocol/schema.js +584 -0
- package/server/registry/certification.js +271 -0
- package/server/registry/index.js +326 -0
- package/server/routes/runtime.js +1136 -0
- package/server/runtime/event-bus.js +210 -0
- package/server/runtime/index.js +233 -0
- package/server/runtime/replay.js +264 -0
- package/server/runtime/sandbox.js +266 -0
- package/server/runtime/scheduler.js +395 -0
- package/server/runtime/session-engine.js +293 -0
- package/server/runtime/state-manager.js +188 -0
- package/server/security/index.js +368 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Runtime - Event Bus
|
|
5
|
+
*
|
|
6
|
+
* Async event system with typed events, middleware, replay buffer,
|
|
7
|
+
* and dead-letter queue. This is the nervous system of the Agent OS.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
class EventBus {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this._listeners = new Map(); // event → Set<{ id, handler, filter, once }>
|
|
15
|
+
this._middleware = []; // global middleware
|
|
16
|
+
this._history = []; // event replay buffer
|
|
17
|
+
this._deadLetter = []; // failed events
|
|
18
|
+
this._maxHistory = options.maxHistory || 10000;
|
|
19
|
+
this._maxDeadLetter = options.maxDeadLetter || 1000;
|
|
20
|
+
this._stats = { emitted: 0, delivered: 0, failed: 0, dropped: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Subscribe to an event
|
|
25
|
+
* @returns {string} subscription ID for unsubscribe
|
|
26
|
+
*/
|
|
27
|
+
on(event, handler, options = {}) {
|
|
28
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
29
|
+
const sub = {
|
|
30
|
+
id: `sub_${crypto.randomBytes(8).toString('hex')}`,
|
|
31
|
+
handler,
|
|
32
|
+
filter: options.filter || null,
|
|
33
|
+
once: options.once || false,
|
|
34
|
+
priority: options.priority || 0,
|
|
35
|
+
};
|
|
36
|
+
this._listeners.get(event).add(sub);
|
|
37
|
+
return sub.id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe once
|
|
42
|
+
*/
|
|
43
|
+
once(event, handler, options = {}) {
|
|
44
|
+
return this.on(event, handler, { ...options, once: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unsubscribe by subscription ID
|
|
49
|
+
*/
|
|
50
|
+
off(subId) {
|
|
51
|
+
for (const [, subs] of this._listeners) {
|
|
52
|
+
for (const sub of subs) {
|
|
53
|
+
if (sub.id === subId) { subs.delete(sub); return true; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove all listeners for an event
|
|
61
|
+
*/
|
|
62
|
+
removeAll(event) {
|
|
63
|
+
if (event) this._listeners.delete(event);
|
|
64
|
+
else this._listeners.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Emit an event
|
|
69
|
+
*/
|
|
70
|
+
async emit(event, data, metadata = {}) {
|
|
71
|
+
const envelope = {
|
|
72
|
+
id: `evt_${crypto.randomBytes(12).toString('hex')}`,
|
|
73
|
+
event,
|
|
74
|
+
data,
|
|
75
|
+
metadata: {
|
|
76
|
+
...metadata,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
source: metadata.source || 'system',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Run global middleware
|
|
83
|
+
for (const mw of this._middleware) {
|
|
84
|
+
try {
|
|
85
|
+
const result = await mw(envelope);
|
|
86
|
+
if (result === false) {
|
|
87
|
+
this._stats.dropped++;
|
|
88
|
+
return envelope;
|
|
89
|
+
}
|
|
90
|
+
} catch (_) { /* middleware errors don't block */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Store in history
|
|
94
|
+
this._history.push(envelope);
|
|
95
|
+
if (this._history.length > this._maxHistory) {
|
|
96
|
+
this._history = this._history.slice(-Math.floor(this._maxHistory * 0.8));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._stats.emitted++;
|
|
100
|
+
|
|
101
|
+
// Get listeners (event + wildcard)
|
|
102
|
+
const listeners = [];
|
|
103
|
+
const exact = this._listeners.get(event);
|
|
104
|
+
if (exact) for (const sub of exact) listeners.push(sub);
|
|
105
|
+
const wild = this._listeners.get('*');
|
|
106
|
+
if (wild) for (const sub of wild) listeners.push(sub);
|
|
107
|
+
|
|
108
|
+
// Also match namespace wildcards: 'task.*' matches 'task.completed'
|
|
109
|
+
for (const [pattern, subs] of this._listeners) {
|
|
110
|
+
if (pattern === event || pattern === '*') continue;
|
|
111
|
+
if (pattern.endsWith('.*') && event.startsWith(pattern.slice(0, -1))) {
|
|
112
|
+
for (const sub of subs) listeners.push(sub);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sort by priority (higher first)
|
|
117
|
+
listeners.sort((a, b) => b.priority - a.priority);
|
|
118
|
+
|
|
119
|
+
// Dispatch
|
|
120
|
+
const toRemove = [];
|
|
121
|
+
for (const sub of listeners) {
|
|
122
|
+
try {
|
|
123
|
+
if (sub.filter && !sub.filter(envelope.data, envelope.metadata)) continue;
|
|
124
|
+
await sub.handler(envelope.data, envelope.metadata, envelope);
|
|
125
|
+
this._stats.delivered++;
|
|
126
|
+
if (sub.once) toRemove.push(sub);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
this._stats.failed++;
|
|
129
|
+
this._deadLetter.push({ envelope, error: err.message, subscriberId: sub.id, timestamp: Date.now() });
|
|
130
|
+
if (this._deadLetter.length > this._maxDeadLetter) {
|
|
131
|
+
this._deadLetter = this._deadLetter.slice(-Math.floor(this._maxDeadLetter * 0.8));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Cleanup one-time subs
|
|
137
|
+
for (const sub of toRemove) {
|
|
138
|
+
for (const [, subs] of this._listeners) subs.delete(sub);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return envelope;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Add global middleware
|
|
146
|
+
*/
|
|
147
|
+
use(middleware) {
|
|
148
|
+
this._middleware.push(middleware);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Replay events matching filter since a timestamp
|
|
153
|
+
*/
|
|
154
|
+
async replay(since, filter, handler) {
|
|
155
|
+
const events = this._history.filter(
|
|
156
|
+
e => e.metadata.timestamp >= since && (!filter || filter(e))
|
|
157
|
+
);
|
|
158
|
+
for (const e of events) {
|
|
159
|
+
await handler(e.data, e.metadata, e);
|
|
160
|
+
}
|
|
161
|
+
return events.length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wait for a specific event (returns a promise)
|
|
166
|
+
*/
|
|
167
|
+
waitFor(event, timeout = 30000, filter = null) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.off(subId);
|
|
171
|
+
reject(new Error(`Timeout waiting for event: ${event}`));
|
|
172
|
+
}, timeout);
|
|
173
|
+
|
|
174
|
+
const subId = this.once(event, (data, meta) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve({ data, meta });
|
|
177
|
+
}, { filter });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get dead letter queue
|
|
183
|
+
*/
|
|
184
|
+
getDeadLetters(limit = 50) {
|
|
185
|
+
return this._deadLetter.slice(-limit);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get stats
|
|
190
|
+
*/
|
|
191
|
+
getStats() {
|
|
192
|
+
return {
|
|
193
|
+
...this._stats,
|
|
194
|
+
listeners: this._countListeners(),
|
|
195
|
+
historySize: this._history.length,
|
|
196
|
+
deadLetterSize: this._deadLetter.length,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_countListeners() {
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const [, subs] of this._listeners) count += subs.size;
|
|
203
|
+
return count;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Singleton event bus for the runtime
|
|
208
|
+
const bus = new EventBus();
|
|
209
|
+
|
|
210
|
+
module.exports = { EventBus, bus };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Runtime - Main Entry Point
|
|
5
|
+
*
|
|
6
|
+
* The Runtime is the core of the Agent OS. It provides:
|
|
7
|
+
* - Task scheduling with retries and timeouts
|
|
8
|
+
* - Agent state management with checkpoints
|
|
9
|
+
* - Execution sandboxing
|
|
10
|
+
* - Event-driven architecture
|
|
11
|
+
*
|
|
12
|
+
* This is NOT a browser wrapper - it's a real execution runtime
|
|
13
|
+
* comparable to Ray or Temporal.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { EventBus, bus } = require('./event-bus');
|
|
17
|
+
const { Scheduler, TaskState } = require('./scheduler');
|
|
18
|
+
const { StateManager } = require('./state-manager');
|
|
19
|
+
const { ExecutionSandbox } = require('./sandbox');
|
|
20
|
+
|
|
21
|
+
class WABRuntime {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.scheduler = new Scheduler({
|
|
24
|
+
maxConcurrent: options.maxConcurrentTasks || 20,
|
|
25
|
+
maxQueueSize: options.maxQueueSize || 1000,
|
|
26
|
+
defaultRetries: options.defaultRetries || 3,
|
|
27
|
+
defaultTimeout: options.defaultTimeout || 30000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
this.state = new StateManager({
|
|
31
|
+
maxCheckpoints: options.maxCheckpoints || 50,
|
|
32
|
+
ttl: options.stateTTL || 24 * 3600_000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.sandbox = new ExecutionSandbox({
|
|
36
|
+
maxConcurrent: options.maxSandboxes || 100,
|
|
37
|
+
defaultTimeout: options.sandboxTimeout || 30000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.events = bus;
|
|
41
|
+
this._started = false;
|
|
42
|
+
this._cleanupTimer = null;
|
|
43
|
+
|
|
44
|
+
// Register built-in task handlers
|
|
45
|
+
this._registerBuiltinHandlers();
|
|
46
|
+
|
|
47
|
+
// Wire events
|
|
48
|
+
this._wireEventHandlers();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start the runtime
|
|
53
|
+
*/
|
|
54
|
+
start() {
|
|
55
|
+
if (this._started) return;
|
|
56
|
+
this._started = true;
|
|
57
|
+
|
|
58
|
+
// Periodic cleanup
|
|
59
|
+
this._cleanupTimer = setInterval(() => {
|
|
60
|
+
this.state.cleanup();
|
|
61
|
+
this.sandbox.cleanup();
|
|
62
|
+
}, 600_000); // 10 min
|
|
63
|
+
if (this._cleanupTimer.unref) this._cleanupTimer.unref();
|
|
64
|
+
|
|
65
|
+
this.events.emit('runtime.started', {
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
capabilities: this.getCapabilities(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop the runtime
|
|
73
|
+
*/
|
|
74
|
+
stop() {
|
|
75
|
+
if (!this._started) return;
|
|
76
|
+
this._started = false;
|
|
77
|
+
if (this._cleanupTimer) clearInterval(this._cleanupTimer);
|
|
78
|
+
this.events.emit('runtime.stopped', { timestamp: Date.now() });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Submit a task
|
|
83
|
+
*/
|
|
84
|
+
submitTask(task) {
|
|
85
|
+
if (!this._started) throw new Error('Runtime not started');
|
|
86
|
+
return this.scheduler.submit(task);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Execute a task in a sandbox with full lifecycle
|
|
91
|
+
*/
|
|
92
|
+
async executeInSandbox(taskId, handler, options = {}) {
|
|
93
|
+
// Create sandbox
|
|
94
|
+
const sbx = this.sandbox.create(taskId, options);
|
|
95
|
+
|
|
96
|
+
// Save initial state
|
|
97
|
+
this.state.save(taskId, { status: 'sandbox_created', sandboxId: sbx.id });
|
|
98
|
+
this.state.checkpoint(taskId, 'pre-execution');
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await this.sandbox.execute(sbx.id, handler);
|
|
102
|
+
|
|
103
|
+
if (result.success) {
|
|
104
|
+
this.state.save(taskId, { status: 'completed', result: result.result });
|
|
105
|
+
} else {
|
|
106
|
+
this.state.save(taskId, { status: 'failed', error: result.error });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
} finally {
|
|
111
|
+
// Cleanup sandbox after a delay
|
|
112
|
+
setTimeout(() => this.sandbox.destroy(sbx.id), 60000);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a task type handler
|
|
118
|
+
*/
|
|
119
|
+
registerTaskHandler(taskType, handler) {
|
|
120
|
+
this.scheduler.registerHandler(taskType, handler);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get runtime capabilities for protocol exposure
|
|
125
|
+
*/
|
|
126
|
+
getCapabilities() {
|
|
127
|
+
return {
|
|
128
|
+
scheduler: { maxConcurrent: 20, retries: true, deadlines: true, dependencies: true },
|
|
129
|
+
state: { checkpoints: true, rollback: true, ttl: true },
|
|
130
|
+
sandbox: { isolation: true, resourceLimits: true, auditTrail: true },
|
|
131
|
+
events: { async: true, replay: true, wildcards: true, deadLetter: true },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get runtime health and stats
|
|
137
|
+
*/
|
|
138
|
+
getHealth() {
|
|
139
|
+
return {
|
|
140
|
+
status: this._started ? 'running' : 'stopped',
|
|
141
|
+
uptime: this._started ? Date.now() : 0,
|
|
142
|
+
scheduler: this.scheduler.getStats(),
|
|
143
|
+
state: this.state.getStats(),
|
|
144
|
+
sandbox: this.sandbox.getStats(),
|
|
145
|
+
events: this.events.getStats(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Built-in Handlers ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
_registerBuiltinHandlers() {
|
|
152
|
+
// Browser task handler (delegated to data plane)
|
|
153
|
+
this.scheduler.registerHandler('browser', async (params, ctx) => {
|
|
154
|
+
ctx.reportProgress(10, 0);
|
|
155
|
+
// Browser tasks are handled by data-plane executor
|
|
156
|
+
return { delegated: 'data-plane', params };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// API task handler
|
|
160
|
+
this.scheduler.registerHandler('api', async (params, ctx) => {
|
|
161
|
+
ctx.reportProgress(10, 0);
|
|
162
|
+
return { delegated: 'data-plane', params };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Extraction task handler
|
|
166
|
+
this.scheduler.registerHandler('extraction', async (params, ctx) => {
|
|
167
|
+
ctx.reportProgress(10, 0);
|
|
168
|
+
return { delegated: 'data-plane', params };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Workflow (composite) handler
|
|
172
|
+
this.scheduler.registerHandler('workflow', async (params, ctx) => {
|
|
173
|
+
const results = [];
|
|
174
|
+
for (let i = 0; i < ctx.steps.length; i++) {
|
|
175
|
+
ctx.reportProgress(Math.floor((i / ctx.steps.length) * 100), i);
|
|
176
|
+
ctx.checkpoint({ step: i, results });
|
|
177
|
+
|
|
178
|
+
const step = ctx.steps[i];
|
|
179
|
+
const handler = this.scheduler._handlers.get(step.type || 'general');
|
|
180
|
+
if (handler) {
|
|
181
|
+
const result = await handler(step.params || {}, ctx);
|
|
182
|
+
results.push({ step: i, result });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
ctx.reportProgress(100, ctx.steps.length);
|
|
186
|
+
return { steps: results };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Composite task (parallel execution)
|
|
190
|
+
this.scheduler.registerHandler('composite', async (params, ctx) => {
|
|
191
|
+
if (!params.tasks || !Array.isArray(params.tasks)) {
|
|
192
|
+
throw new Error('Composite task requires tasks array');
|
|
193
|
+
}
|
|
194
|
+
const promises = params.tasks.map((t, i) => {
|
|
195
|
+
const handler = this.scheduler._handlers.get(t.type || 'general');
|
|
196
|
+
if (!handler) return { step: i, error: `No handler for: ${t.type}` };
|
|
197
|
+
return handler(t.params || {}, ctx).then(r => ({ step: i, result: r }));
|
|
198
|
+
});
|
|
199
|
+
return { results: await Promise.allSettled(promises) };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// General purpose handler (pass-through)
|
|
203
|
+
this.scheduler.registerHandler('general', async (params) => {
|
|
204
|
+
return { received: params };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Event Wiring ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
_wireEventHandlers() {
|
|
211
|
+
// Track task states in state manager
|
|
212
|
+
this.events.on('task.started', (data) => {
|
|
213
|
+
this.state.save(data.taskId, { status: 'running', startedAt: Date.now(), attempt: data.attempt });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.events.on('task.completed', (data) => {
|
|
217
|
+
this.state.merge(data.taskId, { status: 'completed', duration: data.duration });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
this.events.on('task.failed', (data) => {
|
|
221
|
+
this.state.merge(data.taskId, { status: 'failed', error: data.error, attempts: data.attempts });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.events.on('task.progress', (data) => {
|
|
225
|
+
this.state.merge(data.taskId, { progress: data.progress, currentStep: data.step });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Singleton runtime
|
|
231
|
+
const runtime = new WABRuntime();
|
|
232
|
+
|
|
233
|
+
module.exports = { WABRuntime, runtime, EventBus, bus, Scheduler, TaskState, StateManager, ExecutionSandbox };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic Replay Engine
|
|
5
|
+
*
|
|
6
|
+
* Records all task inputs/outputs/side-effects for deterministic replay.
|
|
7
|
+
* Enables debugging, testing, and verification of agent workflows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { bus } = require('../runtime/event-bus');
|
|
12
|
+
|
|
13
|
+
class ReplayEngine {
|
|
14
|
+
constructor() {
|
|
15
|
+
this._recordings = new Map(); // taskId → Recording
|
|
16
|
+
this._maxRecordings = 5000;
|
|
17
|
+
this._recordingEnabled = true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start recording a task execution
|
|
22
|
+
*/
|
|
23
|
+
startRecording(taskId, input) {
|
|
24
|
+
if (!this._recordingEnabled) return null;
|
|
25
|
+
|
|
26
|
+
const recording = {
|
|
27
|
+
id: `rec_${crypto.randomBytes(8).toString('hex')}`,
|
|
28
|
+
taskId,
|
|
29
|
+
input: this._deepClone(input),
|
|
30
|
+
steps: [],
|
|
31
|
+
sideEffects: [],
|
|
32
|
+
startedAt: Date.now(),
|
|
33
|
+
completedAt: null,
|
|
34
|
+
output: null,
|
|
35
|
+
error: null,
|
|
36
|
+
checksum: null,
|
|
37
|
+
replayable: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this._recordings.set(taskId, recording);
|
|
41
|
+
this._evict();
|
|
42
|
+
return recording.id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a step in the execution
|
|
47
|
+
*/
|
|
48
|
+
recordStep(taskId, step) {
|
|
49
|
+
const rec = this._recordings.get(taskId);
|
|
50
|
+
if (!rec) return;
|
|
51
|
+
|
|
52
|
+
rec.steps.push({
|
|
53
|
+
index: rec.steps.length,
|
|
54
|
+
type: step.type,
|
|
55
|
+
action: step.action,
|
|
56
|
+
input: this._deepClone(step.input),
|
|
57
|
+
output: this._deepClone(step.output),
|
|
58
|
+
duration: step.duration || 0,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Record a side effect (network call, DOM mutation, storage write, etc.)
|
|
65
|
+
*/
|
|
66
|
+
recordSideEffect(taskId, effect) {
|
|
67
|
+
const rec = this._recordings.get(taskId);
|
|
68
|
+
if (!rec) return;
|
|
69
|
+
|
|
70
|
+
rec.sideEffects.push({
|
|
71
|
+
index: rec.sideEffects.length,
|
|
72
|
+
type: effect.type, // 'network', 'dom', 'storage', 'event'
|
|
73
|
+
target: effect.target,
|
|
74
|
+
data: this._deepClone(effect.data),
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
reversible: effect.reversible !== false,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Complete a recording
|
|
82
|
+
*/
|
|
83
|
+
completeRecording(taskId, output, error = null) {
|
|
84
|
+
const rec = this._recordings.get(taskId);
|
|
85
|
+
if (!rec) return null;
|
|
86
|
+
|
|
87
|
+
rec.completedAt = Date.now();
|
|
88
|
+
rec.output = this._deepClone(output);
|
|
89
|
+
rec.error = error ? { message: error.message, code: error.code } : null;
|
|
90
|
+
rec.checksum = this._computeChecksum(rec);
|
|
91
|
+
|
|
92
|
+
bus.emit('replay.recording.complete', { taskId, recordingId: rec.id, steps: rec.steps.length });
|
|
93
|
+
return rec;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replay a recorded task
|
|
98
|
+
* Returns the replay plan (steps to execute) with recorded outputs for verification
|
|
99
|
+
*/
|
|
100
|
+
async replay(taskId, options = {}) {
|
|
101
|
+
const rec = this._recordings.get(taskId);
|
|
102
|
+
if (!rec) throw new Error(`No recording found for task ${taskId}`);
|
|
103
|
+
if (!rec.completedAt) throw new Error('Recording not yet complete');
|
|
104
|
+
|
|
105
|
+
const replayResult = {
|
|
106
|
+
recordingId: rec.id,
|
|
107
|
+
taskId,
|
|
108
|
+
originalInput: rec.input,
|
|
109
|
+
originalOutput: rec.output,
|
|
110
|
+
steps: [],
|
|
111
|
+
match: true,
|
|
112
|
+
verificationMode: options.verify !== false,
|
|
113
|
+
replayedAt: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// In verification mode, run each step and compare outputs
|
|
117
|
+
if (options.executor && options.verify !== false) {
|
|
118
|
+
for (const step of rec.steps) {
|
|
119
|
+
try {
|
|
120
|
+
const replayOutput = await options.executor(step);
|
|
121
|
+
const outputMatch = this._deepEqual(step.output, replayOutput);
|
|
122
|
+
|
|
123
|
+
replayResult.steps.push({
|
|
124
|
+
index: step.index,
|
|
125
|
+
action: step.action,
|
|
126
|
+
originalOutput: step.output,
|
|
127
|
+
replayOutput,
|
|
128
|
+
match: outputMatch,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!outputMatch) {
|
|
132
|
+
replayResult.match = false;
|
|
133
|
+
if (!options.continueOnMismatch) break;
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
replayResult.steps.push({
|
|
137
|
+
index: step.index,
|
|
138
|
+
action: step.action,
|
|
139
|
+
error: err.message,
|
|
140
|
+
match: false,
|
|
141
|
+
});
|
|
142
|
+
replayResult.match = false;
|
|
143
|
+
if (!options.continueOnMismatch) break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Dry-run mode: just return the recorded steps
|
|
148
|
+
replayResult.steps = rec.steps.map(s => ({
|
|
149
|
+
index: s.index,
|
|
150
|
+
action: s.action,
|
|
151
|
+
input: s.input,
|
|
152
|
+
output: s.output,
|
|
153
|
+
duration: s.duration,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
bus.emit('replay.completed', { taskId, match: replayResult.match });
|
|
158
|
+
return replayResult;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get recording
|
|
163
|
+
*/
|
|
164
|
+
getRecording(taskId) {
|
|
165
|
+
return this._recordings.get(taskId) || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* List recordings
|
|
170
|
+
*/
|
|
171
|
+
listRecordings(limit = 50) {
|
|
172
|
+
const all = Array.from(this._recordings.values());
|
|
173
|
+
return all.slice(-limit).reverse().map(r => ({
|
|
174
|
+
id: r.id,
|
|
175
|
+
taskId: r.taskId,
|
|
176
|
+
steps: r.steps.length,
|
|
177
|
+
sideEffects: r.sideEffects.length,
|
|
178
|
+
startedAt: r.startedAt,
|
|
179
|
+
completedAt: r.completedAt,
|
|
180
|
+
hasError: !!r.error,
|
|
181
|
+
checksum: r.checksum,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compare two recordings
|
|
187
|
+
*/
|
|
188
|
+
diff(taskId1, taskId2) {
|
|
189
|
+
const r1 = this._recordings.get(taskId1);
|
|
190
|
+
const r2 = this._recordings.get(taskId2);
|
|
191
|
+
if (!r1 || !r2) return null;
|
|
192
|
+
|
|
193
|
+
const diffs = [];
|
|
194
|
+
const maxSteps = Math.max(r1.steps.length, r2.steps.length);
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
197
|
+
const s1 = r1.steps[i];
|
|
198
|
+
const s2 = r2.steps[i];
|
|
199
|
+
|
|
200
|
+
if (!s1 || !s2) {
|
|
201
|
+
diffs.push({ index: i, type: 'missing', in: s1 ? 'recording2' : 'recording1' });
|
|
202
|
+
} else if (!this._deepEqual(s1.output, s2.output)) {
|
|
203
|
+
diffs.push({ index: i, type: 'output_mismatch', action: s1.action, output1: s1.output, output2: s2.output });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
match: diffs.length === 0,
|
|
209
|
+
inputMatch: this._deepEqual(r1.input, r2.input),
|
|
210
|
+
outputMatch: this._deepEqual(r1.output, r2.output),
|
|
211
|
+
diffs,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Enable/disable recording
|
|
217
|
+
*/
|
|
218
|
+
setEnabled(enabled) {
|
|
219
|
+
this._recordingEnabled = enabled;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getStats() {
|
|
223
|
+
return {
|
|
224
|
+
totalRecordings: this._recordings.size,
|
|
225
|
+
enabled: this._recordingEnabled,
|
|
226
|
+
maxRecordings: this._maxRecordings,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Internal ──
|
|
231
|
+
|
|
232
|
+
_computeChecksum(rec) {
|
|
233
|
+
const data = JSON.stringify({
|
|
234
|
+
input: rec.input,
|
|
235
|
+
steps: rec.steps.map(s => ({ action: s.action, output: s.output })),
|
|
236
|
+
output: rec.output,
|
|
237
|
+
});
|
|
238
|
+
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_deepClone(obj) {
|
|
242
|
+
if (obj === undefined || obj === null) return obj;
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(JSON.stringify(obj));
|
|
245
|
+
} catch {
|
|
246
|
+
return obj;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_deepEqual(a, b) {
|
|
251
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_evict() {
|
|
255
|
+
if (this._recordings.size <= this._maxRecordings) return;
|
|
256
|
+
const keys = Array.from(this._recordings.keys());
|
|
257
|
+
const toRemove = keys.slice(0, keys.length - this._maxRecordings);
|
|
258
|
+
for (const k of toRemove) this._recordings.delete(k);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const replayEngine = new ReplayEngine();
|
|
263
|
+
|
|
264
|
+
module.exports = { ReplayEngine, replayEngine };
|