web-agent-bridge 2.6.0 → 2.8.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 +79 -79
- package/sdk/package.json +22 -14
- package/server/config/plans.js +367 -0
- package/server/middleware/featureGate.js +88 -0
- package/server/migrations/005_marketplace_metering.sql +126 -0
- package/server/routes/runtime.js +616 -3
- package/server/services/hosted-runtime.js +205 -0
- package/server/services/lfd.js +616 -0
- package/server/services/marketplace.js +270 -0
- package/server/services/metering.js +182 -0
- package/server/services/vision.js +292 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Learning from Demonstration (LfD) Engine
|
|
5
|
+
*
|
|
6
|
+
* Records user actions on web pages, converts them to replayable recipes,
|
|
7
|
+
* and enables agents to learn from human demonstrations.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. User starts a recording session
|
|
11
|
+
* 2. Browser captures events (clicks, typing, navigation, scrolls)
|
|
12
|
+
* 3. Each event includes DOM snapshot + screenshot hash + element info
|
|
13
|
+
* 4. Session is saved as a "Recipe" (YAML/JSON task template)
|
|
14
|
+
* 5. Recipes can be replayed by agents on the same or similar sites
|
|
15
|
+
* 6. Recipes can be shared via the Marketplace
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
21
|
+
// RECORDING SESSION
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
class RecordingSession {
|
|
25
|
+
constructor(config = {}) {
|
|
26
|
+
this.id = crypto.randomUUID();
|
|
27
|
+
this.name = config.name || 'Untitled Recording';
|
|
28
|
+
this.description = config.description || '';
|
|
29
|
+
this.agentId = config.agentId || null;
|
|
30
|
+
this.startUrl = config.startUrl || '';
|
|
31
|
+
this.status = 'recording'; // recording | paused | completed | cancelled
|
|
32
|
+
this.events = [];
|
|
33
|
+
this.snapshots = []; // DOM snapshots at key moments
|
|
34
|
+
this.metadata = {
|
|
35
|
+
startedAt: Date.now(),
|
|
36
|
+
completedAt: null,
|
|
37
|
+
duration: 0,
|
|
38
|
+
pageCount: 0,
|
|
39
|
+
actionCount: 0,
|
|
40
|
+
domain: '',
|
|
41
|
+
tags: config.tags || [],
|
|
42
|
+
};
|
|
43
|
+
try { this.metadata.domain = new URL(config.startUrl).hostname; } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a user action event
|
|
48
|
+
*/
|
|
49
|
+
addEvent(event) {
|
|
50
|
+
if (this.status !== 'recording') return null;
|
|
51
|
+
|
|
52
|
+
const recorded = {
|
|
53
|
+
id: `evt-${this.events.length}`,
|
|
54
|
+
seq: this.events.length,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
relativeTime: Date.now() - this.metadata.startedAt,
|
|
57
|
+
type: event.type, // click | type | navigate | scroll | select | hover | wait | assert
|
|
58
|
+
target: {
|
|
59
|
+
selector: event.selector || '',
|
|
60
|
+
xpath: event.xpath || '',
|
|
61
|
+
text: (event.text || '').slice(0, 200),
|
|
62
|
+
tag: event.tag || '',
|
|
63
|
+
attributes: event.attributes || {},
|
|
64
|
+
rect: event.rect || {},
|
|
65
|
+
},
|
|
66
|
+
data: {}, // type-specific data
|
|
67
|
+
url: event.url || '',
|
|
68
|
+
pageTitle: event.pageTitle || '',
|
|
69
|
+
screenshot: event.screenshotHash || null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Type-specific data
|
|
73
|
+
switch (event.type) {
|
|
74
|
+
case 'click':
|
|
75
|
+
recorded.data = { x: event.x, y: event.y, button: event.button || 'left', doubleClick: !!event.doubleClick };
|
|
76
|
+
break;
|
|
77
|
+
case 'type':
|
|
78
|
+
recorded.data = { value: event.value || '', key: event.key || '', clearFirst: !!event.clearFirst };
|
|
79
|
+
break;
|
|
80
|
+
case 'navigate':
|
|
81
|
+
recorded.data = { url: event.url || '', method: event.method || 'goto' };
|
|
82
|
+
this.metadata.pageCount++;
|
|
83
|
+
break;
|
|
84
|
+
case 'scroll':
|
|
85
|
+
recorded.data = { x: event.scrollX || 0, y: event.scrollY || 0, direction: event.direction || 'down' };
|
|
86
|
+
break;
|
|
87
|
+
case 'select':
|
|
88
|
+
recorded.data = { value: event.value || '', label: event.label || '', index: event.index };
|
|
89
|
+
break;
|
|
90
|
+
case 'hover':
|
|
91
|
+
recorded.data = { duration: event.duration || 0 };
|
|
92
|
+
break;
|
|
93
|
+
case 'wait':
|
|
94
|
+
recorded.data = { ms: event.ms || 1000, condition: event.condition || 'delay' };
|
|
95
|
+
break;
|
|
96
|
+
case 'assert':
|
|
97
|
+
recorded.data = { assertion: event.assertion || '', expected: event.expected };
|
|
98
|
+
break;
|
|
99
|
+
case 'keypress':
|
|
100
|
+
recorded.data = { key: event.key || '', modifiers: event.modifiers || [] };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.events.push(recorded);
|
|
105
|
+
this.metadata.actionCount = this.events.length;
|
|
106
|
+
return recorded;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Add a DOM snapshot at a key moment
|
|
111
|
+
*/
|
|
112
|
+
addSnapshot(snapshot) {
|
|
113
|
+
if (this.status !== 'recording') return;
|
|
114
|
+
this.snapshots.push({
|
|
115
|
+
seq: this.snapshots.length,
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
url: snapshot.url || '',
|
|
118
|
+
title: snapshot.title || '',
|
|
119
|
+
domHash: snapshot.domHash || '',
|
|
120
|
+
elementCount: snapshot.elementCount || 0,
|
|
121
|
+
interactiveElements: snapshot.interactiveElements || [],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pause() { if (this.status === 'recording') this.status = 'paused'; }
|
|
126
|
+
resume() { if (this.status === 'paused') this.status = 'recording'; }
|
|
127
|
+
|
|
128
|
+
complete() {
|
|
129
|
+
this.status = 'completed';
|
|
130
|
+
this.metadata.completedAt = Date.now();
|
|
131
|
+
this.metadata.duration = this.metadata.completedAt - this.metadata.startedAt;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cancel() {
|
|
135
|
+
this.status = 'cancelled';
|
|
136
|
+
this.metadata.completedAt = Date.now();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert recording to a replayable Recipe
|
|
141
|
+
*/
|
|
142
|
+
toRecipe() {
|
|
143
|
+
return {
|
|
144
|
+
id: crypto.randomUUID(),
|
|
145
|
+
name: this.name,
|
|
146
|
+
description: this.description,
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
sourceRecording: this.id,
|
|
149
|
+
domain: this.metadata.domain,
|
|
150
|
+
startUrl: this.startUrl,
|
|
151
|
+
tags: this.metadata.tags,
|
|
152
|
+
created: new Date().toISOString(),
|
|
153
|
+
steps: this.events.map(evt => this._eventToStep(evt)),
|
|
154
|
+
metadata: {
|
|
155
|
+
recordedBy: this.agentId,
|
|
156
|
+
duration: this.metadata.duration,
|
|
157
|
+
pageCount: this.metadata.pageCount,
|
|
158
|
+
actionCount: this.metadata.actionCount,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_eventToStep(evt) {
|
|
164
|
+
const step = {
|
|
165
|
+
seq: evt.seq,
|
|
166
|
+
action: evt.type,
|
|
167
|
+
selector: evt.target.selector,
|
|
168
|
+
description: this._describeStep(evt),
|
|
169
|
+
wait: { before: 0, after: 200 }, // Default delays
|
|
170
|
+
retry: { maxAttempts: 3, delay: 500 },
|
|
171
|
+
fallback: {},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Add fallback selectors
|
|
175
|
+
if (evt.target.text) step.fallback.text = evt.target.text;
|
|
176
|
+
if (evt.target.xpath) step.fallback.xpath = evt.target.xpath;
|
|
177
|
+
if (evt.target.attributes?.['aria-label']) step.fallback.ariaLabel = evt.target.attributes['aria-label'];
|
|
178
|
+
|
|
179
|
+
switch (evt.type) {
|
|
180
|
+
case 'click':
|
|
181
|
+
step.options = { button: evt.data.button, doubleClick: evt.data.doubleClick };
|
|
182
|
+
break;
|
|
183
|
+
case 'type':
|
|
184
|
+
step.value = evt.data.value;
|
|
185
|
+
step.options = { clearFirst: evt.data.clearFirst };
|
|
186
|
+
break;
|
|
187
|
+
case 'navigate':
|
|
188
|
+
step.url = evt.data.url;
|
|
189
|
+
step.options = { method: evt.data.method };
|
|
190
|
+
break;
|
|
191
|
+
case 'scroll':
|
|
192
|
+
step.options = { x: evt.data.x, y: evt.data.y, direction: evt.data.direction };
|
|
193
|
+
break;
|
|
194
|
+
case 'select':
|
|
195
|
+
step.value = evt.data.value;
|
|
196
|
+
step.options = { label: evt.data.label };
|
|
197
|
+
break;
|
|
198
|
+
case 'wait':
|
|
199
|
+
step.options = { ms: evt.data.ms, condition: evt.data.condition };
|
|
200
|
+
break;
|
|
201
|
+
case 'assert':
|
|
202
|
+
step.options = { assertion: evt.data.assertion, expected: evt.data.expected };
|
|
203
|
+
break;
|
|
204
|
+
case 'keypress':
|
|
205
|
+
step.options = { key: evt.data.key, modifiers: evt.data.modifiers };
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return step;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_describeStep(evt) {
|
|
213
|
+
const target = evt.target.text ? `"${evt.target.text.slice(0, 50)}"` : evt.target.selector;
|
|
214
|
+
switch (evt.type) {
|
|
215
|
+
case 'click': return `Click on ${target}`;
|
|
216
|
+
case 'type': return `Type "${(evt.data.value || '').slice(0, 30)}" into ${target}`;
|
|
217
|
+
case 'navigate': return `Navigate to ${evt.data.url}`;
|
|
218
|
+
case 'scroll': return `Scroll ${evt.data.direction}`;
|
|
219
|
+
case 'select': return `Select "${evt.data.label || evt.data.value}" in ${target}`;
|
|
220
|
+
case 'hover': return `Hover over ${target}`;
|
|
221
|
+
case 'wait': return `Wait ${evt.data.ms}ms`;
|
|
222
|
+
case 'assert': return `Assert ${evt.data.assertion}`;
|
|
223
|
+
case 'keypress': return `Press ${evt.data.modifiers?.length ? evt.data.modifiers.join('+') + '+' : ''}${evt.data.key}`;
|
|
224
|
+
default: return `${evt.type} on ${target}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toJSON() {
|
|
229
|
+
return {
|
|
230
|
+
id: this.id,
|
|
231
|
+
name: this.name,
|
|
232
|
+
description: this.description,
|
|
233
|
+
agentId: this.agentId,
|
|
234
|
+
startUrl: this.startUrl,
|
|
235
|
+
status: this.status,
|
|
236
|
+
events: this.events,
|
|
237
|
+
snapshots: this.snapshots,
|
|
238
|
+
metadata: this.metadata,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
244
|
+
// RECIPE EXECUTOR — Replays recorded recipes
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
class RecipeExecutor {
|
|
248
|
+
constructor() {
|
|
249
|
+
this.executions = new Map();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Start executing a recipe
|
|
254
|
+
* Returns an execution plan that can be consumed step-by-step
|
|
255
|
+
*/
|
|
256
|
+
startExecution(recipe, options = {}) {
|
|
257
|
+
const execution = {
|
|
258
|
+
id: crypto.randomUUID(),
|
|
259
|
+
recipeId: recipe.id,
|
|
260
|
+
recipeName: recipe.name,
|
|
261
|
+
status: 'running', // running | paused | completed | failed | aborted
|
|
262
|
+
currentStep: 0,
|
|
263
|
+
totalSteps: recipe.steps.length,
|
|
264
|
+
startedAt: Date.now(),
|
|
265
|
+
completedAt: null,
|
|
266
|
+
results: [],
|
|
267
|
+
variables: options.variables || {},
|
|
268
|
+
config: {
|
|
269
|
+
speed: options.speed || 1.0, // Playback speed multiplier
|
|
270
|
+
stopOnError: options.stopOnError !== false,
|
|
271
|
+
skipWaits: !!options.skipWaits,
|
|
272
|
+
adaptiveSelectors: options.adaptiveSelectors !== false, // Try fallbacks
|
|
273
|
+
maxRetries: options.maxRetries || 3,
|
|
274
|
+
humanInTheLoop: !!options.humanInTheLoop, // Pause on sensitive actions
|
|
275
|
+
},
|
|
276
|
+
steps: recipe.steps.map(s => ({ ...s })), // Clone steps
|
|
277
|
+
errors: [],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Variable substitution in steps
|
|
281
|
+
if (Object.keys(execution.variables).length > 0) {
|
|
282
|
+
for (const step of execution.steps) {
|
|
283
|
+
if (step.value) step.value = this._substituteVars(step.value, execution.variables);
|
|
284
|
+
if (step.url) step.url = this._substituteVars(step.url, execution.variables);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.executions.set(execution.id, execution);
|
|
289
|
+
return execution;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get next step to execute
|
|
294
|
+
*/
|
|
295
|
+
getNextStep(executionId) {
|
|
296
|
+
const exec = this.executions.get(executionId);
|
|
297
|
+
if (!exec || exec.status !== 'running') return null;
|
|
298
|
+
if (exec.currentStep >= exec.totalSteps) {
|
|
299
|
+
exec.status = 'completed';
|
|
300
|
+
exec.completedAt = Date.now();
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const step = exec.steps[exec.currentStep];
|
|
305
|
+
const sensitiveActions = ['type']; // Actions that might need human approval
|
|
306
|
+
if (exec.config.humanInTheLoop && sensitiveActions.includes(step.action)) {
|
|
307
|
+
step._requiresApproval = true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...step, executionId, stepIndex: exec.currentStep };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Report step result
|
|
315
|
+
*/
|
|
316
|
+
reportStepResult(executionId, stepIndex, result) {
|
|
317
|
+
const exec = this.executions.get(executionId);
|
|
318
|
+
if (!exec) return null;
|
|
319
|
+
|
|
320
|
+
exec.results[stepIndex] = {
|
|
321
|
+
stepIndex,
|
|
322
|
+
action: exec.steps[stepIndex]?.action,
|
|
323
|
+
success: result.success,
|
|
324
|
+
error: result.error || null,
|
|
325
|
+
duration: result.duration || 0,
|
|
326
|
+
timestamp: Date.now(),
|
|
327
|
+
selectorUsed: result.selectorUsed || exec.steps[stepIndex]?.selector,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (result.success) {
|
|
331
|
+
exec.currentStep = stepIndex + 1;
|
|
332
|
+
} else {
|
|
333
|
+
exec.errors.push({ stepIndex, error: result.error, timestamp: Date.now() });
|
|
334
|
+
if (exec.config.stopOnError) {
|
|
335
|
+
exec.status = 'failed';
|
|
336
|
+
exec.completedAt = Date.now();
|
|
337
|
+
} else {
|
|
338
|
+
exec.currentStep = stepIndex + 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Auto-complete if done
|
|
343
|
+
if (exec.currentStep >= exec.totalSteps && exec.status === 'running') {
|
|
344
|
+
exec.status = 'completed';
|
|
345
|
+
exec.completedAt = Date.now();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return exec;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pauseExecution(executionId) {
|
|
352
|
+
const exec = this.executions.get(executionId);
|
|
353
|
+
if (exec && exec.status === 'running') exec.status = 'paused';
|
|
354
|
+
return exec;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
resumeExecution(executionId) {
|
|
358
|
+
const exec = this.executions.get(executionId);
|
|
359
|
+
if (exec && exec.status === 'paused') exec.status = 'running';
|
|
360
|
+
return exec;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
abortExecution(executionId) {
|
|
364
|
+
const exec = this.executions.get(executionId);
|
|
365
|
+
if (exec) { exec.status = 'aborted'; exec.completedAt = Date.now(); }
|
|
366
|
+
return exec;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
getExecution(executionId) { return this.executions.get(executionId) || null; }
|
|
370
|
+
|
|
371
|
+
listExecutions(limit = 50) {
|
|
372
|
+
return [...this.executions.values()]
|
|
373
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
374
|
+
.slice(0, limit);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_substituteVars(str, vars) {
|
|
378
|
+
if (typeof str !== 'string') return str;
|
|
379
|
+
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] !== undefined ? String(vars[key]) : `{{${key}}}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
getStats() {
|
|
383
|
+
const execs = [...this.executions.values()];
|
|
384
|
+
return {
|
|
385
|
+
total: execs.length,
|
|
386
|
+
running: execs.filter(e => e.status === 'running').length,
|
|
387
|
+
completed: execs.filter(e => e.status === 'completed').length,
|
|
388
|
+
failed: execs.filter(e => e.status === 'failed').length,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
394
|
+
// LfD ENGINE — Manages recordings, recipes, and executions
|
|
395
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
396
|
+
|
|
397
|
+
class LfdEngine {
|
|
398
|
+
constructor() {
|
|
399
|
+
this.sessions = new Map(); // Active recording sessions
|
|
400
|
+
this.recipes = new Map(); // Saved recipes
|
|
401
|
+
this.executor = new RecipeExecutor();
|
|
402
|
+
this.stats = {
|
|
403
|
+
totalRecordings: 0,
|
|
404
|
+
totalRecipes: 0,
|
|
405
|
+
totalExecutions: 0,
|
|
406
|
+
totalEvents: 0,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Recording ──
|
|
411
|
+
|
|
412
|
+
startRecording(config) {
|
|
413
|
+
const session = new RecordingSession(config);
|
|
414
|
+
this.sessions.set(session.id, session);
|
|
415
|
+
this.stats.totalRecordings++;
|
|
416
|
+
return { id: session.id, status: session.status, startedAt: session.metadata.startedAt };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
recordEvent(sessionId, event) {
|
|
420
|
+
const session = this.sessions.get(sessionId);
|
|
421
|
+
if (!session) throw new Error('Recording session not found');
|
|
422
|
+
const recorded = session.addEvent(event);
|
|
423
|
+
if (recorded) this.stats.totalEvents++;
|
|
424
|
+
return recorded;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
recordSnapshot(sessionId, snapshot) {
|
|
428
|
+
const session = this.sessions.get(sessionId);
|
|
429
|
+
if (!session) throw new Error('Recording session not found');
|
|
430
|
+
session.addSnapshot(snapshot);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
pauseRecording(sessionId) {
|
|
434
|
+
const session = this.sessions.get(sessionId);
|
|
435
|
+
if (!session) throw new Error('Recording session not found');
|
|
436
|
+
session.pause();
|
|
437
|
+
return { id: sessionId, status: session.status };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
resumeRecording(sessionId) {
|
|
441
|
+
const session = this.sessions.get(sessionId);
|
|
442
|
+
if (!session) throw new Error('Recording session not found');
|
|
443
|
+
session.resume();
|
|
444
|
+
return { id: sessionId, status: session.status };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
stopRecording(sessionId) {
|
|
448
|
+
const session = this.sessions.get(sessionId);
|
|
449
|
+
if (!session) throw new Error('Recording session not found');
|
|
450
|
+
session.complete();
|
|
451
|
+
|
|
452
|
+
// Auto-convert to recipe
|
|
453
|
+
const recipe = session.toRecipe();
|
|
454
|
+
this.recipes.set(recipe.id, recipe);
|
|
455
|
+
this.stats.totalRecipes++;
|
|
456
|
+
|
|
457
|
+
return { recording: session.toJSON(), recipe };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
cancelRecording(sessionId) {
|
|
461
|
+
const session = this.sessions.get(sessionId);
|
|
462
|
+
if (!session) throw new Error('Recording session not found');
|
|
463
|
+
session.cancel();
|
|
464
|
+
return { id: sessionId, status: 'cancelled' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getRecording(sessionId) {
|
|
468
|
+
const session = this.sessions.get(sessionId);
|
|
469
|
+
if (!session) return null;
|
|
470
|
+
return session.toJSON();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
listRecordings(limit = 50) {
|
|
474
|
+
return [...this.sessions.values()]
|
|
475
|
+
.map(s => ({
|
|
476
|
+
id: s.id, name: s.name, status: s.status, domain: s.metadata.domain,
|
|
477
|
+
actionCount: s.metadata.actionCount, duration: s.metadata.duration,
|
|
478
|
+
startedAt: s.metadata.startedAt,
|
|
479
|
+
}))
|
|
480
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
481
|
+
.slice(0, limit);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Recipes ──
|
|
485
|
+
|
|
486
|
+
saveRecipe(recipe) {
|
|
487
|
+
if (!recipe.id) recipe.id = crypto.randomUUID();
|
|
488
|
+
if (!recipe.created) recipe.created = new Date().toISOString();
|
|
489
|
+
this.recipes.set(recipe.id, recipe);
|
|
490
|
+
this.stats.totalRecipes++;
|
|
491
|
+
return recipe;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getRecipe(recipeId) { return this.recipes.get(recipeId) || null; }
|
|
495
|
+
|
|
496
|
+
listRecipes(filters = {}, limit = 50) {
|
|
497
|
+
let recipes = [...this.recipes.values()];
|
|
498
|
+
if (filters.domain) recipes = recipes.filter(r => r.domain === filters.domain);
|
|
499
|
+
if (filters.tag) recipes = recipes.filter(r => r.tags?.includes(filters.tag));
|
|
500
|
+
if (filters.query) {
|
|
501
|
+
const q = filters.query.toLowerCase();
|
|
502
|
+
recipes = recipes.filter(r =>
|
|
503
|
+
r.name.toLowerCase().includes(q) || (r.description || '').toLowerCase().includes(q)
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return recipes.sort((a, b) => new Date(b.created) - new Date(a.created)).slice(0, limit);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
deleteRecipe(recipeId) {
|
|
510
|
+
return this.recipes.delete(recipeId);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Execution ──
|
|
514
|
+
|
|
515
|
+
executeRecipe(recipeId, options = {}) {
|
|
516
|
+
const recipe = this.recipes.get(recipeId);
|
|
517
|
+
if (!recipe) throw new Error('Recipe not found');
|
|
518
|
+
this.stats.totalExecutions++;
|
|
519
|
+
return this.executor.startExecution(recipe, options);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
getNextStep(executionId) { return this.executor.getNextStep(executionId); }
|
|
523
|
+
reportStep(executionId, stepIndex, result) { return this.executor.reportStepResult(executionId, stepIndex, result); }
|
|
524
|
+
pauseExecution(executionId) { return this.executor.pauseExecution(executionId); }
|
|
525
|
+
resumeExecution(executionId) { return this.executor.resumeExecution(executionId); }
|
|
526
|
+
abortExecution(executionId) { return this.executor.abortExecution(executionId); }
|
|
527
|
+
getExecution(executionId) { return this.executor.getExecution(executionId); }
|
|
528
|
+
listExecutions(limit) { return this.executor.listExecutions(limit); }
|
|
529
|
+
|
|
530
|
+
// ── Stats ──
|
|
531
|
+
|
|
532
|
+
getStats() {
|
|
533
|
+
return {
|
|
534
|
+
...this.stats,
|
|
535
|
+
activeRecordings: [...this.sessions.values()].filter(s => s.status === 'recording').length,
|
|
536
|
+
savedRecipes: this.recipes.size,
|
|
537
|
+
executorStats: this.executor.getStats(),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Client-side recording script — inject into pages to capture user actions
|
|
543
|
+
*/
|
|
544
|
+
getRecordingScript(sessionId, serverUrl) {
|
|
545
|
+
return `(function(){
|
|
546
|
+
var SID='${sessionId}',API='${serverUrl || ''}/api/os/lfd';
|
|
547
|
+
var q=[];var sending=false;
|
|
548
|
+
|
|
549
|
+
function send(evt){
|
|
550
|
+
evt.url=location.href;evt.pageTitle=document.title;
|
|
551
|
+
if(API){
|
|
552
|
+
q.push(evt);if(!sending){flush();}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function flush(){
|
|
557
|
+
if(q.length===0){sending=false;return;}
|
|
558
|
+
sending=true;var batch=q.splice(0,10);
|
|
559
|
+
fetch(API+'/'+SID+'/events',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
560
|
+
body:JSON.stringify({events:batch})}).catch(function(){}).finally(function(){setTimeout(flush,100);});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function sel(el){
|
|
564
|
+
if(!el||el===document)return'body';
|
|
565
|
+
if(el.id)return'#'+CSS.escape(el.id);
|
|
566
|
+
var t=el.tagName?.toLowerCase()||'';var c=el.className;
|
|
567
|
+
if(c&&typeof c==='string'){var cls=c.trim().split(/\\s+/).slice(0,2).map(function(x){return'.'+CSS.escape(x);}).join('');if(cls)t+=cls;}
|
|
568
|
+
return t||'unknown';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function attrs(el){
|
|
572
|
+
var a={};if(!el||!el.attributes)return a;
|
|
573
|
+
['id','class','href','type','name','placeholder','role','aria-label','value','alt'].forEach(function(n){
|
|
574
|
+
if(el.hasAttribute(n))a[n]=el.getAttribute(n);
|
|
575
|
+
});return a;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function rect(el){if(!el)return{};var r=el.getBoundingClientRect();return{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)};}
|
|
579
|
+
|
|
580
|
+
document.addEventListener('click',function(e){
|
|
581
|
+
send({type:'click',selector:sel(e.target),tag:e.target.tagName?.toLowerCase(),text:(e.target.textContent||'').trim().substring(0,100),
|
|
582
|
+
attributes:attrs(e.target),rect:rect(e.target),x:e.clientX,y:e.clientY,button:e.button===0?'left':'right'});
|
|
583
|
+
},true);
|
|
584
|
+
|
|
585
|
+
document.addEventListener('input',function(e){
|
|
586
|
+
if(['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)){
|
|
587
|
+
send({type:e.target.tagName==='SELECT'?'select':'type',selector:sel(e.target),tag:e.target.tagName.toLowerCase(),
|
|
588
|
+
text:(e.target.labels?.[0]?.textContent||e.target.placeholder||'').substring(0,100),
|
|
589
|
+
attributes:attrs(e.target),rect:rect(e.target),value:e.target.value?.substring(0,200)});
|
|
590
|
+
}
|
|
591
|
+
},true);
|
|
592
|
+
|
|
593
|
+
document.addEventListener('keydown',function(e){
|
|
594
|
+
if(['Enter','Escape','Tab','Backspace','Delete'].includes(e.key)||e.ctrlKey||e.metaKey){
|
|
595
|
+
send({type:'keypress',key:e.key,modifiers:[e.ctrlKey&&'ctrl',e.shiftKey&&'shift',e.altKey&&'alt',e.metaKey&&'meta'].filter(Boolean),
|
|
596
|
+
selector:sel(e.target),tag:e.target.tagName?.toLowerCase(),attributes:attrs(e.target)});
|
|
597
|
+
}
|
|
598
|
+
},true);
|
|
599
|
+
|
|
600
|
+
var lastScroll=0;
|
|
601
|
+
window.addEventListener('scroll',function(){
|
|
602
|
+
var now=Date.now();if(now-lastScroll<500)return;lastScroll=now;
|
|
603
|
+
send({type:'scroll',scrollX:window.scrollX,scrollY:window.scrollY,direction:window.scrollY>0?'down':'up'});
|
|
604
|
+
},true);
|
|
605
|
+
|
|
606
|
+
// Navigation detection
|
|
607
|
+
var lastUrl=location.href;
|
|
608
|
+
setInterval(function(){if(location.href!==lastUrl){send({type:'navigate',url:location.href,method:'spa'});lastUrl=location.href;}},500);
|
|
609
|
+
|
|
610
|
+
console.log('[WAB LfD] Recording started — session '+SID);
|
|
611
|
+
})();`;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const lfdEngine = new LfdEngine();
|
|
616
|
+
module.exports = { lfdEngine, LfdEngine, RecordingSession, RecipeExecutor };
|