state-surgeon 1.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/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/dashboard/index.js +1192 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/index.mjs +1181 -0
- package/dist/dashboard/index.mjs.map +1 -0
- package/dist/index.js +1828 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1798 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instrument/index.js +828 -0
- package/dist/instrument/index.js.map +1 -0
- package/dist/instrument/index.mjs +819 -0
- package/dist/instrument/index.mjs.map +1 -0
- package/dist/recorder/index.js +882 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/recorder/index.mjs +873 -0
- package/dist/recorder/index.mjs.map +1 -0
- package/package.json +94 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var express = require('express');
|
|
4
|
+
var ws = require('ws');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var express__default = /*#__PURE__*/_interopDefault(express);
|
|
9
|
+
|
|
10
|
+
// src/recorder/api.ts
|
|
11
|
+
function createAPIRoutes(store, reconstructor) {
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
router.get("/health", (_req, res) => {
|
|
14
|
+
res.json({ status: "ok", timestamp: Date.now() });
|
|
15
|
+
});
|
|
16
|
+
router.get("/sessions", (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const appId = req.query.appId;
|
|
19
|
+
const sessions = store.getSessions(appId);
|
|
20
|
+
res.json({ sessions });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
res.status(500).json({ error: String(error) });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
router.get("/sessions/:sessionId", (req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
const session = store.getSession(req.params.sessionId);
|
|
28
|
+
if (!session) {
|
|
29
|
+
return res.status(404).json({ error: "Session not found" });
|
|
30
|
+
}
|
|
31
|
+
res.json({ session });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
res.status(500).json({ error: String(error) });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
router.delete("/sessions/:sessionId", (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const deleted = store.deleteSession(req.params.sessionId);
|
|
39
|
+
res.json({ deleted });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
res.status(500).json({ error: String(error) });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
router.get("/sessions/:sessionId/timeline", (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const timeline = store.getTimeline(req.params.sessionId);
|
|
47
|
+
res.json({ timeline, count: timeline.length });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
res.status(500).json({ error: String(error) });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
router.get("/mutations", (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const options = {
|
|
55
|
+
sessionId: req.query.sessionId,
|
|
56
|
+
startTime: req.query.startTime ? Number(req.query.startTime) : void 0,
|
|
57
|
+
endTime: req.query.endTime ? Number(req.query.endTime) : void 0,
|
|
58
|
+
source: req.query.source,
|
|
59
|
+
component: req.query.component,
|
|
60
|
+
limit: req.query.limit ? Number(req.query.limit) : 100,
|
|
61
|
+
offset: req.query.offset ? Number(req.query.offset) : 0,
|
|
62
|
+
sortOrder: req.query.sortOrder || "asc"
|
|
63
|
+
};
|
|
64
|
+
const mutations = store.queryMutations(options);
|
|
65
|
+
res.json({ mutations, count: mutations.length });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
res.status(500).json({ error: String(error) });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
router.get("/mutations/:mutationId", (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const mutation = store.getMutation(req.params.mutationId);
|
|
73
|
+
if (!mutation) {
|
|
74
|
+
return res.status(404).json({ error: "Mutation not found" });
|
|
75
|
+
}
|
|
76
|
+
res.json({ mutation });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
res.status(500).json({ error: String(error) });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
router.get("/mutations/:mutationId/state", async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const state = await reconstructor.getStateAtMutation(req.params.mutationId);
|
|
84
|
+
res.json({ state });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
res.status(500).json({ error: String(error) });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
router.get("/mutations/:mutationId/chain", async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : 100;
|
|
92
|
+
const chain = await reconstructor.findCausalChain(req.params.mutationId, maxDepth);
|
|
93
|
+
res.json({ chain });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
res.status(500).json({ error: String(error) });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
router.get("/sessions/:sessionId/path", async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const path = req.query.path;
|
|
101
|
+
if (!path) {
|
|
102
|
+
return res.status(400).json({ error: "Path is required" });
|
|
103
|
+
}
|
|
104
|
+
const mutations = await reconstructor.findMutationsForPath(req.params.sessionId, path);
|
|
105
|
+
res.json({ mutations, count: mutations.length });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
res.status(500).json({ error: String(error) });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
router.get("/sessions/:sessionId/summary", async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const startTime = Number(req.query.startTime);
|
|
113
|
+
const endTime = Number(req.query.endTime);
|
|
114
|
+
if (isNaN(startTime) || isNaN(endTime)) {
|
|
115
|
+
return res.status(400).json({ error: "startTime and endTime are required" });
|
|
116
|
+
}
|
|
117
|
+
const summary = await reconstructor.summarizeTimeRange(
|
|
118
|
+
req.params.sessionId,
|
|
119
|
+
startTime,
|
|
120
|
+
endTime
|
|
121
|
+
);
|
|
122
|
+
res.json({ summary });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
res.status(500).json({ error: String(error) });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
router.get("/stats", (_req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const stats = store.getStats();
|
|
130
|
+
res.json({ stats });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
res.status(500).json({ error: String(error) });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
router.delete("/clear", (_req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
store.clear();
|
|
138
|
+
reconstructor.clearCache();
|
|
139
|
+
res.json({ success: true });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
res.status(500).json({ error: String(error) });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return router;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/core/diff.ts
|
|
148
|
+
function deepClone(obj) {
|
|
149
|
+
if (obj === null || typeof obj !== "object") {
|
|
150
|
+
return obj;
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(obj)) {
|
|
153
|
+
return obj.map((item) => deepClone(item));
|
|
154
|
+
}
|
|
155
|
+
if (obj instanceof Date) {
|
|
156
|
+
return new Date(obj.getTime());
|
|
157
|
+
}
|
|
158
|
+
if (obj instanceof Map) {
|
|
159
|
+
const clonedMap = /* @__PURE__ */ new Map();
|
|
160
|
+
obj.forEach((value, key) => {
|
|
161
|
+
clonedMap.set(deepClone(key), deepClone(value));
|
|
162
|
+
});
|
|
163
|
+
return clonedMap;
|
|
164
|
+
}
|
|
165
|
+
if (obj instanceof Set) {
|
|
166
|
+
const clonedSet = /* @__PURE__ */ new Set();
|
|
167
|
+
obj.forEach((value) => {
|
|
168
|
+
clonedSet.add(deepClone(value));
|
|
169
|
+
});
|
|
170
|
+
return clonedSet;
|
|
171
|
+
}
|
|
172
|
+
const cloned = {};
|
|
173
|
+
for (const key in obj) {
|
|
174
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
175
|
+
cloned[key] = deepClone(obj[key]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return cloned;
|
|
179
|
+
}
|
|
180
|
+
function calculateDiff(before, after, path = "") {
|
|
181
|
+
const diffs = [];
|
|
182
|
+
if (before === after) {
|
|
183
|
+
return diffs;
|
|
184
|
+
}
|
|
185
|
+
if (before === null || before === void 0 || typeof before !== "object") {
|
|
186
|
+
if (after === null || after === void 0 || typeof after !== "object") {
|
|
187
|
+
if (before !== after) {
|
|
188
|
+
if (before === void 0) {
|
|
189
|
+
diffs.push({ path: path || "root", operation: "ADD", newValue: after });
|
|
190
|
+
} else if (after === void 0) {
|
|
191
|
+
diffs.push({ path: path || "root", operation: "REMOVE", oldValue: before });
|
|
192
|
+
} else {
|
|
193
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return diffs;
|
|
197
|
+
}
|
|
198
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
199
|
+
return diffs;
|
|
200
|
+
}
|
|
201
|
+
if (after === null || after === void 0 || typeof after !== "object") {
|
|
202
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
203
|
+
return diffs;
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
206
|
+
if (!Array.isArray(before) || !Array.isArray(after)) {
|
|
207
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
208
|
+
return diffs;
|
|
209
|
+
}
|
|
210
|
+
const maxLength = Math.max(before.length, after.length);
|
|
211
|
+
for (let i = 0; i < maxLength; i++) {
|
|
212
|
+
const itemPath = path ? `${path}[${i}]` : `[${i}]`;
|
|
213
|
+
if (i >= before.length) {
|
|
214
|
+
diffs.push({ path: itemPath, operation: "ADD", newValue: after[i] });
|
|
215
|
+
} else if (i >= after.length) {
|
|
216
|
+
diffs.push({ path: itemPath, operation: "REMOVE", oldValue: before[i] });
|
|
217
|
+
} else {
|
|
218
|
+
diffs.push(...calculateDiff(before[i], after[i], itemPath));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return diffs;
|
|
222
|
+
}
|
|
223
|
+
const beforeObj = before;
|
|
224
|
+
const afterObj = after;
|
|
225
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]);
|
|
226
|
+
for (const key of allKeys) {
|
|
227
|
+
const keyPath = path ? `${path}.${key}` : key;
|
|
228
|
+
const beforeValue = beforeObj[key];
|
|
229
|
+
const afterValue = afterObj[key];
|
|
230
|
+
if (!(key in beforeObj)) {
|
|
231
|
+
diffs.push({ path: keyPath, operation: "ADD", newValue: afterValue });
|
|
232
|
+
} else if (!(key in afterObj)) {
|
|
233
|
+
diffs.push({ path: keyPath, operation: "REMOVE", oldValue: beforeValue });
|
|
234
|
+
} else {
|
|
235
|
+
diffs.push(...calculateDiff(beforeValue, afterValue, keyPath));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return diffs;
|
|
239
|
+
}
|
|
240
|
+
function getValueAtPath(obj, path) {
|
|
241
|
+
if (!path || path === "root") return obj;
|
|
242
|
+
const parts = parsePath(path);
|
|
243
|
+
let current = obj;
|
|
244
|
+
for (const part of parts) {
|
|
245
|
+
if (current === null || current === void 0) {
|
|
246
|
+
return void 0;
|
|
247
|
+
}
|
|
248
|
+
current = current[part];
|
|
249
|
+
}
|
|
250
|
+
return current;
|
|
251
|
+
}
|
|
252
|
+
function parsePath(path) {
|
|
253
|
+
const parts = [];
|
|
254
|
+
let current = "";
|
|
255
|
+
let inBracket = false;
|
|
256
|
+
for (const char of path) {
|
|
257
|
+
if (char === "." && !inBracket) {
|
|
258
|
+
if (current) parts.push(current);
|
|
259
|
+
current = "";
|
|
260
|
+
} else if (char === "[") {
|
|
261
|
+
if (current) parts.push(current);
|
|
262
|
+
current = "";
|
|
263
|
+
inBracket = true;
|
|
264
|
+
} else if (char === "]") {
|
|
265
|
+
if (current) parts.push(current);
|
|
266
|
+
current = "";
|
|
267
|
+
inBracket = false;
|
|
268
|
+
} else {
|
|
269
|
+
current += char;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (current) parts.push(current);
|
|
273
|
+
return parts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/recorder/reconstructor.ts
|
|
277
|
+
var TimelineReconstructor = class {
|
|
278
|
+
constructor(store) {
|
|
279
|
+
this.stateCache = /* @__PURE__ */ new Map();
|
|
280
|
+
this.maxCacheSize = 1e3;
|
|
281
|
+
this.store = store;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Reconstructs the state at a specific mutation
|
|
285
|
+
*/
|
|
286
|
+
async getStateAtMutation(mutationId) {
|
|
287
|
+
const cached = this.stateCache.get(mutationId);
|
|
288
|
+
if (cached !== void 0) {
|
|
289
|
+
return deepClone(cached);
|
|
290
|
+
}
|
|
291
|
+
const targetMutation = this.store.getMutation(mutationId);
|
|
292
|
+
if (!targetMutation) {
|
|
293
|
+
throw new Error(`Mutation not found: ${mutationId}`);
|
|
294
|
+
}
|
|
295
|
+
const timeline = this.store.getTimeline(targetMutation.sessionId);
|
|
296
|
+
const targetIndex = timeline.findIndex((m) => m.id === mutationId);
|
|
297
|
+
if (targetIndex === -1) {
|
|
298
|
+
throw new Error(`Mutation not in timeline: ${mutationId}`);
|
|
299
|
+
}
|
|
300
|
+
let state = {};
|
|
301
|
+
for (let i = 0; i <= targetIndex; i++) {
|
|
302
|
+
const mutation = timeline[i];
|
|
303
|
+
state = mutation.nextState;
|
|
304
|
+
this.cacheState(mutation.id, state);
|
|
305
|
+
}
|
|
306
|
+
return deepClone(state);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Reconstructs the state at a specific timestamp
|
|
310
|
+
*/
|
|
311
|
+
async getStateAtTime(sessionId, timestamp) {
|
|
312
|
+
const timeline = this.store.getTimeline(sessionId);
|
|
313
|
+
let lastMutation = null;
|
|
314
|
+
for (const mutation of timeline) {
|
|
315
|
+
if (mutation.timestamp <= timestamp) {
|
|
316
|
+
lastMutation = mutation;
|
|
317
|
+
} else {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!lastMutation) {
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
return this.getStateAtMutation(lastMutation.id);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Finds the causal chain leading to a mutation
|
|
328
|
+
*/
|
|
329
|
+
async findCausalChain(mutationId, maxDepth = 100) {
|
|
330
|
+
const chain = [];
|
|
331
|
+
let currentId = mutationId;
|
|
332
|
+
let depth = 0;
|
|
333
|
+
while (currentId && depth < maxDepth) {
|
|
334
|
+
const mutation = this.store.getMutation(currentId);
|
|
335
|
+
if (!mutation) break;
|
|
336
|
+
chain.push(mutation);
|
|
337
|
+
if (mutation.parentMutationId) {
|
|
338
|
+
currentId = mutation.parentMutationId;
|
|
339
|
+
} else if (mutation.causes && mutation.causes.length > 0) {
|
|
340
|
+
currentId = mutation.causes[0];
|
|
341
|
+
} else {
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
depth++;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
mutations: chain.reverse(),
|
|
348
|
+
rootCause: chain[0]
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Binary search to find when state became invalid
|
|
353
|
+
*/
|
|
354
|
+
async findStateCorruption(sessionId, validator) {
|
|
355
|
+
const timeline = this.store.getTimeline(sessionId);
|
|
356
|
+
if (timeline.length === 0) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
let left = 0;
|
|
360
|
+
let right = timeline.length - 1;
|
|
361
|
+
let firstBad = null;
|
|
362
|
+
while (left <= right) {
|
|
363
|
+
const mid = Math.floor((left + right) / 2);
|
|
364
|
+
const state = await this.getStateAtMutation(timeline[mid].id);
|
|
365
|
+
const isValid = validator(state);
|
|
366
|
+
if (isValid) {
|
|
367
|
+
left = mid + 1;
|
|
368
|
+
} else {
|
|
369
|
+
firstBad = mid;
|
|
370
|
+
right = mid - 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return firstBad !== null ? timeline[firstBad] : null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Finds all mutations that touched a specific path
|
|
377
|
+
*/
|
|
378
|
+
async findMutationsForPath(sessionId, path) {
|
|
379
|
+
const timeline = this.store.getTimeline(sessionId);
|
|
380
|
+
const result = [];
|
|
381
|
+
for (const mutation of timeline) {
|
|
382
|
+
if (mutation.diff) {
|
|
383
|
+
for (const diff of mutation.diff) {
|
|
384
|
+
if (diff.path === path || diff.path.startsWith(path + ".") || diff.path.startsWith(path + "[")) {
|
|
385
|
+
result.push(mutation);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
const prevValue = getValueAtPath(mutation.previousState, path);
|
|
391
|
+
const nextValue = getValueAtPath(mutation.nextState, path);
|
|
392
|
+
if (prevValue !== nextValue) {
|
|
393
|
+
result.push(mutation);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Compares two sessions to find divergence points
|
|
401
|
+
*/
|
|
402
|
+
async compareSessions(sessionId1, sessionId2) {
|
|
403
|
+
const timeline1 = this.store.getTimeline(sessionId1);
|
|
404
|
+
const timeline2 = this.store.getTimeline(sessionId2);
|
|
405
|
+
let divergencePoint;
|
|
406
|
+
const minLength = Math.min(timeline1.length, timeline2.length);
|
|
407
|
+
for (let i = 0; i < minLength; i++) {
|
|
408
|
+
const state1 = await this.getStateAtMutation(timeline1[i].id);
|
|
409
|
+
const state2 = await this.getStateAtMutation(timeline2[i].id);
|
|
410
|
+
if (JSON.stringify(state1) !== JSON.stringify(state2)) {
|
|
411
|
+
divergencePoint = i;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const actions1 = new Set(timeline1.map((m) => `${m.actionType}:${JSON.stringify(m.actionPayload)}`));
|
|
416
|
+
const actions2 = new Set(timeline2.map((m) => `${m.actionType}:${JSON.stringify(m.actionPayload)}`));
|
|
417
|
+
const session1Only = timeline1.filter(
|
|
418
|
+
(m) => !actions2.has(`${m.actionType}:${JSON.stringify(m.actionPayload)}`)
|
|
419
|
+
);
|
|
420
|
+
const session2Only = timeline2.filter(
|
|
421
|
+
(m) => !actions1.has(`${m.actionType}:${JSON.stringify(m.actionPayload)}`)
|
|
422
|
+
);
|
|
423
|
+
return {
|
|
424
|
+
divergencePoint,
|
|
425
|
+
session1Only,
|
|
426
|
+
session2Only
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Generates a summary of what happened in a time range
|
|
431
|
+
*/
|
|
432
|
+
async summarizeTimeRange(sessionId, startTime, endTime) {
|
|
433
|
+
const mutations = this.store.queryMutations({
|
|
434
|
+
sessionId,
|
|
435
|
+
startTime,
|
|
436
|
+
endTime
|
|
437
|
+
});
|
|
438
|
+
const componentBreakdown = {};
|
|
439
|
+
const sourceBreakdown = {};
|
|
440
|
+
const pathsSet = /* @__PURE__ */ new Set();
|
|
441
|
+
for (const mutation of mutations) {
|
|
442
|
+
const component = mutation.component || "unknown";
|
|
443
|
+
componentBreakdown[component] = (componentBreakdown[component] || 0) + 1;
|
|
444
|
+
sourceBreakdown[mutation.source] = (sourceBreakdown[mutation.source] || 0) + 1;
|
|
445
|
+
if (mutation.diff) {
|
|
446
|
+
for (const diff of mutation.diff) {
|
|
447
|
+
pathsSet.add(diff.path);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
mutationCount: mutations.length,
|
|
453
|
+
componentBreakdown,
|
|
454
|
+
sourceBreakdown,
|
|
455
|
+
pathsChanged: Array.from(pathsSet)
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Clears the state cache
|
|
460
|
+
*/
|
|
461
|
+
clearCache() {
|
|
462
|
+
this.stateCache.clear();
|
|
463
|
+
}
|
|
464
|
+
cacheState(mutationId, state) {
|
|
465
|
+
if (this.stateCache.size >= this.maxCacheSize) {
|
|
466
|
+
const firstKey = this.stateCache.keys().next().value;
|
|
467
|
+
if (firstKey) {
|
|
468
|
+
this.stateCache.delete(firstKey);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
this.stateCache.set(mutationId, deepClone(state));
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// src/recorder/store.ts
|
|
476
|
+
var MutationStore = class {
|
|
477
|
+
constructor(options = {}) {
|
|
478
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
479
|
+
this.mutations = /* @__PURE__ */ new Map();
|
|
480
|
+
this.totalMutationCount = 0;
|
|
481
|
+
this.options = {
|
|
482
|
+
maxMutationsPerSession: options.maxMutationsPerSession ?? 1e4,
|
|
483
|
+
maxTotalMutations: options.maxTotalMutations ?? 1e5,
|
|
484
|
+
sessionTimeout: options.sessionTimeout ?? 30 * 60 * 1e3,
|
|
485
|
+
// 30 minutes
|
|
486
|
+
debug: options.debug ?? false
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Creates or updates a session
|
|
491
|
+
*/
|
|
492
|
+
registerSession(sessionId, appId, metadata) {
|
|
493
|
+
let session = this.sessions.get(sessionId);
|
|
494
|
+
if (!session) {
|
|
495
|
+
session = {
|
|
496
|
+
id: sessionId,
|
|
497
|
+
appId,
|
|
498
|
+
startTime: /* @__PURE__ */ new Date(),
|
|
499
|
+
metadata,
|
|
500
|
+
mutationCount: 0
|
|
501
|
+
};
|
|
502
|
+
this.sessions.set(sessionId, session);
|
|
503
|
+
this.mutations.set(sessionId, []);
|
|
504
|
+
this.log("Session registered:", sessionId);
|
|
505
|
+
}
|
|
506
|
+
return session;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Ends a session
|
|
510
|
+
*/
|
|
511
|
+
endSession(sessionId) {
|
|
512
|
+
const session = this.sessions.get(sessionId);
|
|
513
|
+
if (session) {
|
|
514
|
+
session.endTime = /* @__PURE__ */ new Date();
|
|
515
|
+
this.log("Session ended:", sessionId);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Stores a mutation
|
|
520
|
+
*/
|
|
521
|
+
storeMutation(mutation) {
|
|
522
|
+
const sessionId = mutation.sessionId;
|
|
523
|
+
if (!this.sessions.has(sessionId)) {
|
|
524
|
+
this.registerSession(sessionId, "unknown");
|
|
525
|
+
}
|
|
526
|
+
let sessionMutations = this.mutations.get(sessionId);
|
|
527
|
+
if (!sessionMutations) {
|
|
528
|
+
sessionMutations = [];
|
|
529
|
+
this.mutations.set(sessionId, sessionMutations);
|
|
530
|
+
}
|
|
531
|
+
if (!mutation.diff && mutation.previousState !== void 0 && mutation.nextState !== void 0) {
|
|
532
|
+
mutation.diff = calculateDiff(mutation.previousState, mutation.nextState);
|
|
533
|
+
}
|
|
534
|
+
if (sessionMutations.length >= this.options.maxMutationsPerSession) {
|
|
535
|
+
sessionMutations.shift();
|
|
536
|
+
this.totalMutationCount--;
|
|
537
|
+
}
|
|
538
|
+
if (this.totalMutationCount >= this.options.maxTotalMutations) {
|
|
539
|
+
this.evictOldestMutations();
|
|
540
|
+
}
|
|
541
|
+
sessionMutations.push(mutation);
|
|
542
|
+
this.totalMutationCount++;
|
|
543
|
+
const session = this.sessions.get(sessionId);
|
|
544
|
+
session.mutationCount++;
|
|
545
|
+
this.log("Mutation stored:", mutation.id);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Stores multiple mutations at once
|
|
549
|
+
*/
|
|
550
|
+
storeMutations(mutations) {
|
|
551
|
+
for (const mutation of mutations) {
|
|
552
|
+
this.storeMutation(mutation);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Gets a session by ID
|
|
557
|
+
*/
|
|
558
|
+
getSession(sessionId) {
|
|
559
|
+
return this.sessions.get(sessionId);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Gets all sessions
|
|
563
|
+
*/
|
|
564
|
+
getSessions(appId) {
|
|
565
|
+
const sessions = Array.from(this.sessions.values());
|
|
566
|
+
if (appId) {
|
|
567
|
+
return sessions.filter((s) => s.appId === appId);
|
|
568
|
+
}
|
|
569
|
+
return sessions;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Gets a specific mutation by ID
|
|
573
|
+
*/
|
|
574
|
+
getMutation(mutationId) {
|
|
575
|
+
for (const sessionMutations of this.mutations.values()) {
|
|
576
|
+
const mutation = sessionMutations.find((m) => m.id === mutationId);
|
|
577
|
+
if (mutation) return mutation;
|
|
578
|
+
}
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Queries mutations with filters
|
|
583
|
+
*/
|
|
584
|
+
queryMutations(options = {}) {
|
|
585
|
+
let results = [];
|
|
586
|
+
if (options.sessionId) {
|
|
587
|
+
const sessionMutations = this.mutations.get(options.sessionId);
|
|
588
|
+
if (sessionMutations) {
|
|
589
|
+
results = [...sessionMutations];
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
for (const sessionMutations of this.mutations.values()) {
|
|
593
|
+
results.push(...sessionMutations);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (options.startTime !== void 0) {
|
|
597
|
+
results = results.filter((m) => m.timestamp >= options.startTime);
|
|
598
|
+
}
|
|
599
|
+
if (options.endTime !== void 0) {
|
|
600
|
+
results = results.filter((m) => m.timestamp <= options.endTime);
|
|
601
|
+
}
|
|
602
|
+
if (options.source) {
|
|
603
|
+
results = results.filter((m) => m.source === options.source);
|
|
604
|
+
}
|
|
605
|
+
if (options.component) {
|
|
606
|
+
results = results.filter((m) => m.component === options.component);
|
|
607
|
+
}
|
|
608
|
+
results.sort((a, b) => {
|
|
609
|
+
const order = options.sortOrder === "desc" ? -1 : 1;
|
|
610
|
+
return (a.logicalClock - b.logicalClock) * order;
|
|
611
|
+
});
|
|
612
|
+
if (options.offset !== void 0) {
|
|
613
|
+
results = results.slice(options.offset);
|
|
614
|
+
}
|
|
615
|
+
if (options.limit !== void 0) {
|
|
616
|
+
results = results.slice(0, options.limit);
|
|
617
|
+
}
|
|
618
|
+
return results;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Gets the timeline for a session
|
|
622
|
+
*/
|
|
623
|
+
getTimeline(sessionId) {
|
|
624
|
+
const mutations = this.mutations.get(sessionId) || [];
|
|
625
|
+
return [...mutations].sort((a, b) => a.logicalClock - b.logicalClock);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Deletes a session and its mutations
|
|
629
|
+
*/
|
|
630
|
+
deleteSession(sessionId) {
|
|
631
|
+
const sessionMutations = this.mutations.get(sessionId);
|
|
632
|
+
if (sessionMutations) {
|
|
633
|
+
this.totalMutationCount -= sessionMutations.length;
|
|
634
|
+
}
|
|
635
|
+
this.mutations.delete(sessionId);
|
|
636
|
+
return this.sessions.delete(sessionId);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Clears all data
|
|
640
|
+
*/
|
|
641
|
+
clear() {
|
|
642
|
+
this.sessions.clear();
|
|
643
|
+
this.mutations.clear();
|
|
644
|
+
this.totalMutationCount = 0;
|
|
645
|
+
this.log("Store cleared");
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Gets store statistics
|
|
649
|
+
*/
|
|
650
|
+
getStats() {
|
|
651
|
+
const mutationsPerSession = {};
|
|
652
|
+
for (const [sessionId, mutations] of this.mutations.entries()) {
|
|
653
|
+
mutationsPerSession[sessionId] = mutations.length;
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
sessionCount: this.sessions.size,
|
|
657
|
+
totalMutations: this.totalMutationCount,
|
|
658
|
+
mutationsPerSession
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Evicts oldest mutations when capacity is exceeded
|
|
663
|
+
*/
|
|
664
|
+
evictOldestMutations() {
|
|
665
|
+
let oldestSession = null;
|
|
666
|
+
let oldestTime = Infinity;
|
|
667
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
668
|
+
if (session.startTime.getTime() < oldestTime) {
|
|
669
|
+
oldestTime = session.startTime.getTime();
|
|
670
|
+
oldestSession = sessionId;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (oldestSession) {
|
|
674
|
+
const mutations = this.mutations.get(oldestSession);
|
|
675
|
+
if (mutations && mutations.length > 0) {
|
|
676
|
+
mutations.shift();
|
|
677
|
+
this.totalMutationCount--;
|
|
678
|
+
this.log("Evicted oldest mutation from:", oldestSession);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
log(...args) {
|
|
683
|
+
if (this.options.debug) {
|
|
684
|
+
console.log("[MutationStore]", ...args);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// src/recorder/server.ts
|
|
690
|
+
function createRecorderServer(options = {}) {
|
|
691
|
+
const port = options.port ?? 8080;
|
|
692
|
+
const wsPort = options.wsPort ?? 8081;
|
|
693
|
+
const apiPath = options.apiPath ?? "/api";
|
|
694
|
+
const debug = options.debug ?? false;
|
|
695
|
+
const store = new MutationStore(options.storeOptions);
|
|
696
|
+
const reconstructor = new TimelineReconstructor(store);
|
|
697
|
+
const app = express__default.default();
|
|
698
|
+
app.use(express__default.default.json({ limit: "10mb" }));
|
|
699
|
+
if (options.cors !== false) {
|
|
700
|
+
app.use((_req, res, next) => {
|
|
701
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
702
|
+
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
703
|
+
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
|
|
704
|
+
next();
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
app.use(apiPath, createAPIRoutes(store, reconstructor));
|
|
708
|
+
app.get("/_surgeon", (_req, res) => {
|
|
709
|
+
res.send(`
|
|
710
|
+
<!DOCTYPE html>
|
|
711
|
+
<html>
|
|
712
|
+
<head>
|
|
713
|
+
<title>State Surgeon Dashboard</title>
|
|
714
|
+
<style>
|
|
715
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; background: #1a1a2e; color: #eee; }
|
|
716
|
+
h1 { color: #00d9ff; }
|
|
717
|
+
.stats { background: #16213e; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
|
|
718
|
+
pre { background: #0f0f23; padding: 1rem; border-radius: 4px; overflow: auto; }
|
|
719
|
+
</style>
|
|
720
|
+
</head>
|
|
721
|
+
<body>
|
|
722
|
+
<h1>\u{1F52C} State Surgeon Dashboard</h1>
|
|
723
|
+
<p>Recorder is running. Connect your application to start capturing mutations.</p>
|
|
724
|
+
<div class="stats">
|
|
725
|
+
<h3>Quick Stats</h3>
|
|
726
|
+
<div id="stats">Loading...</div>
|
|
727
|
+
</div>
|
|
728
|
+
<h3>Recent Sessions</h3>
|
|
729
|
+
<pre id="sessions">Loading...</pre>
|
|
730
|
+
<script>
|
|
731
|
+
async function loadData() {
|
|
732
|
+
try {
|
|
733
|
+
const statsRes = await fetch('${apiPath}/stats');
|
|
734
|
+
const stats = await statsRes.json();
|
|
735
|
+
document.getElementById('stats').innerHTML =
|
|
736
|
+
'<pre>' + JSON.stringify(stats, null, 2) + '</pre>';
|
|
737
|
+
|
|
738
|
+
const sessionsRes = await fetch('${apiPath}/sessions');
|
|
739
|
+
const sessions = await sessionsRes.json();
|
|
740
|
+
document.getElementById('sessions').textContent =
|
|
741
|
+
JSON.stringify(sessions, null, 2);
|
|
742
|
+
} catch (e) {
|
|
743
|
+
document.getElementById('stats').textContent = 'Error loading stats';
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
loadData();
|
|
747
|
+
setInterval(loadData, 5000);
|
|
748
|
+
</script>
|
|
749
|
+
</body>
|
|
750
|
+
</html>
|
|
751
|
+
`);
|
|
752
|
+
});
|
|
753
|
+
const httpServer = app.listen(0);
|
|
754
|
+
httpServer.close();
|
|
755
|
+
let wss;
|
|
756
|
+
const clients = /* @__PURE__ */ new Map();
|
|
757
|
+
function log(...args) {
|
|
758
|
+
if (debug) {
|
|
759
|
+
console.log("[State Surgeon Recorder]", ...args);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function handleMessage(ws, message) {
|
|
763
|
+
log("Received message:", message.type);
|
|
764
|
+
switch (message.type) {
|
|
765
|
+
case "REGISTER_SESSION": {
|
|
766
|
+
const payload = message.payload;
|
|
767
|
+
store.registerSession(payload.sessionId, payload.appId, {
|
|
768
|
+
userAgent: payload.userAgent,
|
|
769
|
+
url: payload.url
|
|
770
|
+
});
|
|
771
|
+
clients.set(ws, { sessionId: payload.sessionId });
|
|
772
|
+
ws.send(JSON.stringify({
|
|
773
|
+
type: "SESSION_CONFIRMED",
|
|
774
|
+
payload: { sessionId: payload.sessionId }
|
|
775
|
+
}));
|
|
776
|
+
log("Session registered:", payload.sessionId);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "MUTATION_RECORDED": {
|
|
780
|
+
const mutation = message.payload;
|
|
781
|
+
store.storeMutation(mutation);
|
|
782
|
+
ws.send(JSON.stringify({
|
|
783
|
+
type: "MUTATION_ACKNOWLEDGED",
|
|
784
|
+
payload: { mutationId: mutation.id, receivedAt: Date.now() }
|
|
785
|
+
}));
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case "MUTATION_BATCH": {
|
|
789
|
+
const payload = message.payload;
|
|
790
|
+
store.storeMutations(payload.mutations);
|
|
791
|
+
ws.send(JSON.stringify({
|
|
792
|
+
type: "BATCH_ACKNOWLEDGED",
|
|
793
|
+
payload: { count: payload.mutations.length, receivedAt: Date.now() }
|
|
794
|
+
}));
|
|
795
|
+
log("Batch received:", payload.mutations.length, "mutations");
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case "END_SESSION": {
|
|
799
|
+
const clientInfo = clients.get(ws);
|
|
800
|
+
if (clientInfo?.sessionId) {
|
|
801
|
+
store.endSession(clientInfo.sessionId);
|
|
802
|
+
log("Session ended:", clientInfo.sessionId);
|
|
803
|
+
}
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
default:
|
|
807
|
+
log("Unknown message type:", message.type);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const server = {
|
|
811
|
+
app,
|
|
812
|
+
httpServer: null,
|
|
813
|
+
wss: null,
|
|
814
|
+
store,
|
|
815
|
+
reconstructor,
|
|
816
|
+
async start() {
|
|
817
|
+
return new Promise((resolve) => {
|
|
818
|
+
server.httpServer = app.listen(port, () => {
|
|
819
|
+
log(`HTTP server listening on port ${port}`);
|
|
820
|
+
});
|
|
821
|
+
wss = new ws.WebSocketServer({ port: wsPort });
|
|
822
|
+
server.wss = wss;
|
|
823
|
+
wss.on("connection", (ws) => {
|
|
824
|
+
log("Client connected");
|
|
825
|
+
clients.set(ws, {});
|
|
826
|
+
ws.on("message", (data) => {
|
|
827
|
+
try {
|
|
828
|
+
const message = JSON.parse(data.toString());
|
|
829
|
+
handleMessage(ws, message);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
log("Error parsing message:", error);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
ws.on("close", () => {
|
|
835
|
+
const clientInfo = clients.get(ws);
|
|
836
|
+
if (clientInfo?.sessionId) {
|
|
837
|
+
store.endSession(clientInfo.sessionId);
|
|
838
|
+
}
|
|
839
|
+
clients.delete(ws);
|
|
840
|
+
log("Client disconnected");
|
|
841
|
+
});
|
|
842
|
+
ws.on("error", (error) => {
|
|
843
|
+
log("WebSocket error:", error);
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
wss.on("listening", () => {
|
|
847
|
+
log(`WebSocket server listening on port ${wsPort}`);
|
|
848
|
+
resolve();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
async stop() {
|
|
853
|
+
return new Promise((resolve) => {
|
|
854
|
+
for (const ws of clients.keys()) {
|
|
855
|
+
ws.close();
|
|
856
|
+
}
|
|
857
|
+
clients.clear();
|
|
858
|
+
if (wss) {
|
|
859
|
+
wss.close(() => {
|
|
860
|
+
log("WebSocket server closed");
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
if (server.httpServer) {
|
|
864
|
+
server.httpServer.close(() => {
|
|
865
|
+
log("HTTP server closed");
|
|
866
|
+
resolve();
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
869
|
+
resolve();
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
return server;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
exports.MutationStore = MutationStore;
|
|
878
|
+
exports.TimelineReconstructor = TimelineReconstructor;
|
|
879
|
+
exports.createAPIRoutes = createAPIRoutes;
|
|
880
|
+
exports.createRecorderServer = createRecorderServer;
|
|
881
|
+
//# sourceMappingURL=index.js.map
|
|
882
|
+
//# sourceMappingURL=index.js.map
|