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.
@@ -5,14 +5,129 @@
5
5
  *
6
6
  * Records all task inputs/outputs/side-effects for deterministic replay.
7
7
  * Enables debugging, testing, and verification of agent workflows.
8
+ *
9
+ * v2 Upgrades:
10
+ * - SQLite persistence (survives restarts)
11
+ * - Event sourcing integration (every action is an event)
12
+ * - Checkpoint persistence
13
+ * - Recording export/import
14
+ * - Diff between any two runs
8
15
  */
9
16
 
10
17
  const crypto = require('crypto');
11
18
  const { bus } = require('../runtime/event-bus');
19
+ const { db } = require('../models/db');
20
+
21
+ // ─── Schema ──────────────────────────────────────────────────────────
22
+
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS replay_recordings (
25
+ id TEXT PRIMARY KEY,
26
+ task_id TEXT NOT NULL,
27
+ input TEXT DEFAULT '{}',
28
+ output TEXT,
29
+ error TEXT,
30
+ checksum TEXT,
31
+ steps_count INTEGER DEFAULT 0,
32
+ side_effects_count INTEGER DEFAULT 0,
33
+ replayable INTEGER DEFAULT 1,
34
+ started_at INTEGER NOT NULL,
35
+ completed_at INTEGER,
36
+ UNIQUE(task_id)
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS replay_events (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ recording_id TEXT NOT NULL,
42
+ task_id TEXT NOT NULL,
43
+ event_index INTEGER NOT NULL,
44
+ event_type TEXT NOT NULL,
45
+ action TEXT,
46
+ input TEXT,
47
+ output TEXT,
48
+ duration_ms INTEGER DEFAULT 0,
49
+ timestamp INTEGER NOT NULL,
50
+ FOREIGN KEY (recording_id) REFERENCES replay_recordings(id)
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS replay_side_effects (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ recording_id TEXT NOT NULL,
56
+ task_id TEXT NOT NULL,
57
+ effect_index INTEGER NOT NULL,
58
+ effect_type TEXT NOT NULL,
59
+ target TEXT,
60
+ data TEXT,
61
+ reversible INTEGER DEFAULT 1,
62
+ timestamp INTEGER NOT NULL,
63
+ FOREIGN KEY (recording_id) REFERENCES replay_recordings(id)
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS replay_checkpoints (
67
+ id TEXT PRIMARY KEY,
68
+ recording_id TEXT NOT NULL,
69
+ task_id TEXT NOT NULL,
70
+ label TEXT,
71
+ state TEXT DEFAULT '{}',
72
+ step_index INTEGER,
73
+ created_at INTEGER NOT NULL,
74
+ FOREIGN KEY (recording_id) REFERENCES replay_recordings(id)
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS idx_replay_events_rec ON replay_events(recording_id);
78
+ CREATE INDEX IF NOT EXISTS idx_replay_events_task ON replay_events(task_id);
79
+ CREATE INDEX IF NOT EXISTS idx_replay_se_rec ON replay_side_effects(recording_id);
80
+ CREATE INDEX IF NOT EXISTS idx_replay_cp_rec ON replay_checkpoints(recording_id);
81
+ CREATE INDEX IF NOT EXISTS idx_replay_rec_task ON replay_recordings(task_id);
82
+ `);
83
+
84
+ // ─── Prepared Statements ─────────────────────────────────────────────
85
+
86
+ const stmts = {
87
+ insertRec: db.prepare(`
88
+ INSERT OR REPLACE INTO replay_recordings (id, task_id, input, started_at)
89
+ VALUES (@id, @task_id, @input, @started_at)
90
+ `),
91
+ completeRec: db.prepare(`
92
+ UPDATE replay_recordings SET output=@output, error=@error, checksum=@checksum,
93
+ steps_count=@steps_count, side_effects_count=@side_effects_count, completed_at=@completed_at
94
+ WHERE id=@id
95
+ `),
96
+ getRec: db.prepare(`SELECT * FROM replay_recordings WHERE task_id=?`),
97
+ getRecById: db.prepare(`SELECT * FROM replay_recordings WHERE id=?`),
98
+ listRecs: db.prepare(`SELECT * FROM replay_recordings ORDER BY started_at DESC LIMIT ?`),
99
+ deleteRec: db.prepare(`DELETE FROM replay_recordings WHERE id=?`),
100
+ countRecs: db.prepare(`SELECT COUNT(*) as count FROM replay_recordings`),
101
+
102
+ insertEvent: db.prepare(`
103
+ INSERT INTO replay_events (recording_id, task_id, event_index, event_type, action, input, output, duration_ms, timestamp)
104
+ VALUES (@recording_id, @task_id, @event_index, @event_type, @action, @input, @output, @duration_ms, @timestamp)
105
+ `),
106
+ getEvents: db.prepare(`SELECT * FROM replay_events WHERE recording_id=? ORDER BY event_index ASC`),
107
+ countEvents: db.prepare(`SELECT COUNT(*) as count FROM replay_events WHERE recording_id=?`),
108
+
109
+ insertSideEffect: db.prepare(`
110
+ INSERT INTO replay_side_effects (recording_id, task_id, effect_index, effect_type, target, data, reversible, timestamp)
111
+ VALUES (@recording_id, @task_id, @effect_index, @effect_type, @target, @data, @reversible, @timestamp)
112
+ `),
113
+ getSideEffects: db.prepare(`SELECT * FROM replay_side_effects WHERE recording_id=? ORDER BY effect_index ASC`),
114
+
115
+ insertCheckpoint: db.prepare(`
116
+ INSERT INTO replay_checkpoints (id, recording_id, task_id, label, state, step_index, created_at)
117
+ VALUES (@id, @recording_id, @task_id, @label, @state, @step_index, @created_at)
118
+ `),
119
+ getCheckpoints: db.prepare(`SELECT * FROM replay_checkpoints WHERE recording_id=? ORDER BY created_at ASC`),
120
+
121
+ deleteEvents: db.prepare(`DELETE FROM replay_events WHERE recording_id=?`),
122
+ deleteSideEffects: db.prepare(`DELETE FROM replay_side_effects WHERE recording_id=?`),
123
+ deleteCheckpoints: db.prepare(`DELETE FROM replay_checkpoints WHERE recording_id=?`),
124
+
125
+ purgeOld: db.prepare(`DELETE FROM replay_recordings WHERE completed_at < ?`),
126
+ };
12
127
 
13
128
  class ReplayEngine {
14
129
  constructor() {
15
- this._recordings = new Map(); // taskId → Recording
130
+ this._recordings = new Map(); // taskId → Recording (hot cache)
16
131
  this._maxRecordings = 5000;
17
132
  this._recordingEnabled = true;
18
133
  }
@@ -29,6 +144,7 @@ class ReplayEngine {
29
144
  input: this._deepClone(input),
30
145
  steps: [],
31
146
  sideEffects: [],
147
+ checkpoints: [],
32
148
  startedAt: Date.now(),
33
149
  completedAt: null,
34
150
  output: null,
@@ -39,25 +155,58 @@ class ReplayEngine {
39
155
 
40
156
  this._recordings.set(taskId, recording);
41
157
  this._evict();
158
+
159
+ // Persist to DB
160
+ try {
161
+ stmts.insertRec.run({
162
+ id: recording.id,
163
+ task_id: taskId,
164
+ input: JSON.stringify(recording.input),
165
+ started_at: recording.startedAt,
166
+ });
167
+ } catch {}
168
+
169
+ bus.emit('replay.recording.started', { taskId, recordingId: recording.id });
42
170
  return recording.id;
43
171
  }
44
172
 
45
173
  /**
46
- * Record a step in the execution
174
+ * Record a step in the execution (event sourced)
47
175
  */
48
176
  recordStep(taskId, step) {
49
177
  const rec = this._recordings.get(taskId);
50
178
  if (!rec) return;
51
179
 
52
- rec.steps.push({
53
- index: rec.steps.length,
180
+ const idx = rec.steps.length;
181
+ const entry = {
182
+ index: idx,
54
183
  type: step.type,
55
184
  action: step.action,
56
185
  input: this._deepClone(step.input),
57
186
  output: this._deepClone(step.output),
58
187
  duration: step.duration || 0,
59
188
  timestamp: Date.now(),
60
- });
189
+ };
190
+
191
+ rec.steps.push(entry);
192
+
193
+ // Persist event
194
+ try {
195
+ stmts.insertEvent.run({
196
+ recording_id: rec.id,
197
+ task_id: taskId,
198
+ event_index: idx,
199
+ event_type: step.type || 'step',
200
+ action: step.action || '',
201
+ input: JSON.stringify(step.input),
202
+ output: JSON.stringify(step.output),
203
+ duration_ms: step.duration || 0,
204
+ timestamp: entry.timestamp,
205
+ });
206
+ } catch {}
207
+
208
+ // Emit for real-time observability
209
+ bus.emit('replay.step', { taskId, index: idx, action: step.action });
61
210
  }
62
211
 
63
212
  /**
@@ -67,14 +216,64 @@ class ReplayEngine {
67
216
  const rec = this._recordings.get(taskId);
68
217
  if (!rec) return;
69
218
 
70
- rec.sideEffects.push({
71
- index: rec.sideEffects.length,
72
- type: effect.type, // 'network', 'dom', 'storage', 'event'
219
+ const idx = rec.sideEffects.length;
220
+ const entry = {
221
+ index: idx,
222
+ type: effect.type,
73
223
  target: effect.target,
74
224
  data: this._deepClone(effect.data),
75
225
  timestamp: Date.now(),
76
226
  reversible: effect.reversible !== false,
77
- });
227
+ };
228
+
229
+ rec.sideEffects.push(entry);
230
+
231
+ // Persist
232
+ try {
233
+ stmts.insertSideEffect.run({
234
+ recording_id: rec.id,
235
+ task_id: taskId,
236
+ effect_index: idx,
237
+ effect_type: effect.type || 'unknown',
238
+ target: effect.target || '',
239
+ data: JSON.stringify(effect.data),
240
+ reversible: effect.reversible !== false ? 1 : 0,
241
+ timestamp: entry.timestamp,
242
+ });
243
+ } catch {}
244
+ }
245
+
246
+ /**
247
+ * Save a checkpoint during recording (for partial replay)
248
+ */
249
+ saveCheckpoint(taskId, label, state) {
250
+ const rec = this._recordings.get(taskId);
251
+ if (!rec) return null;
252
+
253
+ const cp = {
254
+ id: `rcp_${crypto.randomBytes(8).toString('hex')}`,
255
+ label: label || `step-${rec.steps.length}`,
256
+ state: this._deepClone(state),
257
+ stepIndex: rec.steps.length,
258
+ createdAt: Date.now(),
259
+ };
260
+
261
+ rec.checkpoints.push(cp);
262
+
263
+ // Persist
264
+ try {
265
+ stmts.insertCheckpoint.run({
266
+ id: cp.id,
267
+ recording_id: rec.id,
268
+ task_id: taskId,
269
+ label: cp.label,
270
+ state: JSON.stringify(cp.state),
271
+ step_index: cp.stepIndex,
272
+ created_at: cp.createdAt,
273
+ });
274
+ } catch {}
275
+
276
+ return cp.id;
78
277
  }
79
278
 
80
279
  /**
@@ -89,17 +288,35 @@ class ReplayEngine {
89
288
  rec.error = error ? { message: error.message, code: error.code } : null;
90
289
  rec.checksum = this._computeChecksum(rec);
91
290
 
291
+ // Persist completion
292
+ try {
293
+ stmts.completeRec.run({
294
+ id: rec.id,
295
+ output: JSON.stringify(rec.output),
296
+ error: rec.error ? JSON.stringify(rec.error) : null,
297
+ checksum: rec.checksum,
298
+ steps_count: rec.steps.length,
299
+ side_effects_count: rec.sideEffects.length,
300
+ completed_at: rec.completedAt,
301
+ });
302
+ } catch {}
303
+
92
304
  bus.emit('replay.recording.complete', { taskId, recordingId: rec.id, steps: rec.steps.length });
93
305
  return rec;
94
306
  }
95
307
 
96
308
  /**
97
309
  * Replay a recorded task
98
- * Returns the replay plan (steps to execute) with recorded outputs for verification
99
310
  */
100
311
  async replay(taskId, options = {}) {
101
- const rec = this._recordings.get(taskId);
102
- if (!rec) throw new Error(`No recording found for task ${taskId}`);
312
+ let rec = this._recordings.get(taskId);
313
+
314
+ // Load from DB if not in cache
315
+ if (!rec) {
316
+ rec = this._loadFromDB(taskId);
317
+ if (!rec) throw new Error(`No recording found for task ${taskId}`);
318
+ }
319
+
103
320
  if (!rec.completedAt) throw new Error('Recording not yet complete');
104
321
 
105
322
  const replayResult = {
@@ -110,12 +327,24 @@ class ReplayEngine {
110
327
  steps: [],
111
328
  match: true,
112
329
  verificationMode: options.verify !== false,
330
+ fromCheckpoint: null,
113
331
  replayedAt: Date.now(),
114
332
  };
115
333
 
116
- // In verification mode, run each step and compare outputs
334
+ // Start from a checkpoint if specified
335
+ let startIndex = 0;
336
+ if (options.fromCheckpoint) {
337
+ const cp = rec.checkpoints.find(c => c.id === options.fromCheckpoint || c.label === options.fromCheckpoint);
338
+ if (cp) {
339
+ startIndex = cp.stepIndex;
340
+ replayResult.fromCheckpoint = cp.label;
341
+ }
342
+ }
343
+
344
+ const stepsToReplay = rec.steps.slice(startIndex);
345
+
117
346
  if (options.executor && options.verify !== false) {
118
- for (const step of rec.steps) {
347
+ for (const step of stepsToReplay) {
119
348
  try {
120
349
  const replayOutput = await options.executor(step);
121
350
  const outputMatch = this._deepEqual(step.output, replayOutput);
@@ -144,8 +373,7 @@ class ReplayEngine {
144
373
  }
145
374
  }
146
375
  } else {
147
- // Dry-run mode: just return the recorded steps
148
- replayResult.steps = rec.steps.map(s => ({
376
+ replayResult.steps = stepsToReplay.map(s => ({
149
377
  index: s.index,
150
378
  action: s.action,
151
379
  input: s.input,
@@ -158,36 +386,145 @@ class ReplayEngine {
158
386
  return replayResult;
159
387
  }
160
388
 
389
+ /**
390
+ * Export a full recording (for sharing/debugging)
391
+ */
392
+ exportRecording(taskId) {
393
+ let rec = this._recordings.get(taskId);
394
+ if (!rec) rec = this._loadFromDB(taskId);
395
+ if (!rec) return null;
396
+
397
+ return {
398
+ id: rec.id,
399
+ taskId: rec.taskId,
400
+ input: rec.input,
401
+ output: rec.output,
402
+ error: rec.error,
403
+ checksum: rec.checksum,
404
+ steps: rec.steps,
405
+ sideEffects: rec.sideEffects,
406
+ checkpoints: rec.checkpoints,
407
+ startedAt: rec.startedAt,
408
+ completedAt: rec.completedAt,
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Import a recording
414
+ */
415
+ importRecording(data) {
416
+ const recording = {
417
+ id: data.id || `rec_${crypto.randomBytes(8).toString('hex')}`,
418
+ taskId: data.taskId,
419
+ input: data.input || {},
420
+ steps: data.steps || [],
421
+ sideEffects: data.sideEffects || [],
422
+ checkpoints: data.checkpoints || [],
423
+ startedAt: data.startedAt || Date.now(),
424
+ completedAt: data.completedAt,
425
+ output: data.output,
426
+ error: data.error,
427
+ checksum: data.checksum,
428
+ replayable: true,
429
+ };
430
+
431
+ this._recordings.set(data.taskId, recording);
432
+
433
+ // Persist
434
+ try {
435
+ stmts.insertRec.run({
436
+ id: recording.id,
437
+ task_id: recording.taskId,
438
+ input: JSON.stringify(recording.input),
439
+ started_at: recording.startedAt,
440
+ });
441
+
442
+ for (const step of recording.steps) {
443
+ stmts.insertEvent.run({
444
+ recording_id: recording.id,
445
+ task_id: recording.taskId,
446
+ event_index: step.index,
447
+ event_type: step.type || 'step',
448
+ action: step.action || '',
449
+ input: JSON.stringify(step.input),
450
+ output: JSON.stringify(step.output),
451
+ duration_ms: step.duration || 0,
452
+ timestamp: step.timestamp || Date.now(),
453
+ });
454
+ }
455
+
456
+ if (recording.completedAt) {
457
+ stmts.completeRec.run({
458
+ id: recording.id,
459
+ output: JSON.stringify(recording.output),
460
+ error: recording.error ? JSON.stringify(recording.error) : null,
461
+ checksum: recording.checksum || this._computeChecksum(recording),
462
+ steps_count: recording.steps.length,
463
+ side_effects_count: recording.sideEffects.length,
464
+ completed_at: recording.completedAt,
465
+ });
466
+ }
467
+ } catch {}
468
+
469
+ return recording.id;
470
+ }
471
+
161
472
  /**
162
473
  * Get recording
163
474
  */
164
475
  getRecording(taskId) {
165
- return this._recordings.get(taskId) || null;
476
+ let rec = this._recordings.get(taskId);
477
+ if (!rec) rec = this._loadFromDB(taskId);
478
+ return rec || null;
166
479
  }
167
480
 
168
481
  /**
169
482
  * List recordings
170
483
  */
171
484
  listRecordings(limit = 50) {
172
- const all = Array.from(this._recordings.values());
173
- return all.slice(-limit).reverse().map(r => ({
485
+ const rows = stmts.listRecs.all(limit);
486
+ return rows.map(r => ({
174
487
  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,
488
+ taskId: r.task_id,
489
+ steps: r.steps_count,
490
+ sideEffects: r.side_effects_count,
491
+ startedAt: r.started_at,
492
+ completedAt: r.completed_at,
180
493
  hasError: !!r.error,
181
494
  checksum: r.checksum,
182
495
  }));
183
496
  }
184
497
 
498
+ /**
499
+ * Delete a recording and all its events
500
+ */
501
+ deleteRecording(taskId) {
502
+ const rec = this._recordings.get(taskId);
503
+ const recId = rec ? rec.id : null;
504
+ this._recordings.delete(taskId);
505
+
506
+ if (recId) {
507
+ stmts.deleteEvents.run(recId);
508
+ stmts.deleteSideEffects.run(recId);
509
+ stmts.deleteCheckpoints.run(recId);
510
+ stmts.deleteRec.run(recId);
511
+ } else {
512
+ const dbRec = stmts.getRec.get(taskId);
513
+ if (dbRec) {
514
+ stmts.deleteEvents.run(dbRec.id);
515
+ stmts.deleteSideEffects.run(dbRec.id);
516
+ stmts.deleteCheckpoints.run(dbRec.id);
517
+ stmts.deleteRec.run(dbRec.id);
518
+ }
519
+ }
520
+ }
521
+
185
522
  /**
186
523
  * Compare two recordings
187
524
  */
188
525
  diff(taskId1, taskId2) {
189
- const r1 = this._recordings.get(taskId1);
190
- const r2 = this._recordings.get(taskId2);
526
+ let r1 = this._recordings.get(taskId1) || this._loadFromDB(taskId1);
527
+ let r2 = this._recordings.get(taskId2) || this._loadFromDB(taskId2);
191
528
  if (!r1 || !r2) return null;
192
529
 
193
530
  const diffs = [];
@@ -213,22 +550,81 @@ class ReplayEngine {
213
550
  }
214
551
 
215
552
  /**
216
- * Enable/disable recording
553
+ * Purge old recordings
217
554
  */
555
+ purgeOld(maxAgeMs = 7 * 24 * 3600_000) {
556
+ const cutoff = Date.now() - maxAgeMs;
557
+ stmts.purgeOld.run(cutoff);
558
+ }
559
+
218
560
  setEnabled(enabled) {
219
561
  this._recordingEnabled = enabled;
220
562
  }
221
563
 
222
564
  getStats() {
565
+ const dbCount = stmts.countRecs.get();
223
566
  return {
224
- totalRecordings: this._recordings.size,
567
+ totalRecordings: dbCount ? dbCount.count : this._recordings.size,
568
+ cachedRecordings: this._recordings.size,
225
569
  enabled: this._recordingEnabled,
226
- maxRecordings: this._maxRecordings,
570
+ persistent: true,
227
571
  };
228
572
  }
229
573
 
230
574
  // ── Internal ──
231
575
 
576
+ /**
577
+ * Load a recording from DB (including all events/side-effects/checkpoints)
578
+ */
579
+ _loadFromDB(taskId) {
580
+ const row = stmts.getRec.get(taskId);
581
+ if (!row) return null;
582
+
583
+ const events = stmts.getEvents.all(row.id);
584
+ const sideEffects = stmts.getSideEffects.all(row.id);
585
+ const checkpoints = stmts.getCheckpoints.all(row.id);
586
+
587
+ const recording = {
588
+ id: row.id,
589
+ taskId: row.task_id,
590
+ input: _safeParse(row.input, {}),
591
+ output: _safeParse(row.output, null),
592
+ error: _safeParse(row.error, null),
593
+ checksum: row.checksum,
594
+ replayable: !!row.replayable,
595
+ startedAt: row.started_at,
596
+ completedAt: row.completed_at,
597
+ steps: events.map(e => ({
598
+ index: e.event_index,
599
+ type: e.event_type,
600
+ action: e.action,
601
+ input: _safeParse(e.input, null),
602
+ output: _safeParse(e.output, null),
603
+ duration: e.duration_ms,
604
+ timestamp: e.timestamp,
605
+ })),
606
+ sideEffects: sideEffects.map(s => ({
607
+ index: s.effect_index,
608
+ type: s.effect_type,
609
+ target: s.target,
610
+ data: _safeParse(s.data, null),
611
+ reversible: !!s.reversible,
612
+ timestamp: s.timestamp,
613
+ })),
614
+ checkpoints: checkpoints.map(c => ({
615
+ id: c.id,
616
+ label: c.label,
617
+ state: _safeParse(c.state, {}),
618
+ stepIndex: c.step_index,
619
+ createdAt: c.created_at,
620
+ })),
621
+ };
622
+
623
+ // Cache it
624
+ this._recordings.set(taskId, recording);
625
+ return recording;
626
+ }
627
+
232
628
  _computeChecksum(rec) {
233
629
  const data = JSON.stringify({
234
630
  input: rec.input,
@@ -259,6 +655,12 @@ class ReplayEngine {
259
655
  }
260
656
  }
261
657
 
658
+ function _safeParse(str, fallback) {
659
+ if (str == null) return fallback;
660
+ if (typeof str === 'object') return str;
661
+ try { return JSON.parse(str); } catch { return fallback; }
662
+ }
663
+
262
664
  const replayEngine = new ReplayEngine();
263
665
 
264
666
  module.exports = { ReplayEngine, replayEngine };