web-agent-bridge 2.9.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 +1 -1
- package/sdk/package.json +1 -1
- package/server/routes/runtime.js +204 -0
- package/server/runtime/container-worker.js +111 -0
- package/server/runtime/container.js +448 -0
- package/server/runtime/distributed-worker.js +362 -0
- package/server/runtime/index.js +21 -1
- package/server/runtime/queue.js +599 -0
- package/server/runtime/replay.js +431 -29
- package/server/runtime/scheduler.js +194 -55
package/server/runtime/replay.js
CHANGED
|
@@ -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.
|
|
53
|
-
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
return
|
|
485
|
+
const rows = stmts.listRecs.all(limit);
|
|
486
|
+
return rows.map(r => ({
|
|
174
487
|
id: r.id,
|
|
175
|
-
taskId: r.
|
|
176
|
-
steps: r.
|
|
177
|
-
sideEffects: r.
|
|
178
|
-
startedAt: r.
|
|
179
|
-
completedAt: r.
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 };
|