@pro-vi/designer 0.3.10 → 0.3.11
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/dist/cdp-ensure.js +3 -0
- package/dist/cdp-trace.js +509 -0
- package/dist/designer-controller.js +74 -5
- package/dist/mcp-server.js +1 -1
- package/dist/run-state.js +327 -0
- package/dist/scripts/ci-health.js +1 -0
- package/dist/scripts/trace-analyze.js +366 -0
- package/dist/scripts/trace-spike.js +274 -0
- package/dist/ui-anchors.js +96 -2
- package/package.json +6 -4
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { CdpSession } from "./cdp-trace.js";
|
|
2
|
+
export const OMELETTE_TURN_SERVICE = 'anthropic.omelette.api.v1alpha.OmeletteService';
|
|
3
|
+
export const TURN_RPCS = ['Chat', 'RenewTurn', 'ReleaseTurn'];
|
|
4
|
+
function asRec(v) {
|
|
5
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
6
|
+
}
|
|
7
|
+
function num(v) {
|
|
8
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
9
|
+
}
|
|
10
|
+
function eventWallMs(params) {
|
|
11
|
+
const syntheticTraceTs = num(params.ts);
|
|
12
|
+
if (syntheticTraceTs !== null)
|
|
13
|
+
return syntheticTraceTs;
|
|
14
|
+
const wallTime = num(params.wallTime);
|
|
15
|
+
return wallTime !== null ? wallTime * 1000 : null;
|
|
16
|
+
}
|
|
17
|
+
function requestId(params) {
|
|
18
|
+
return typeof params.requestId === 'string' ? params.requestId : undefined;
|
|
19
|
+
}
|
|
20
|
+
export function turnRpcFromUrl(url) {
|
|
21
|
+
if (!url)
|
|
22
|
+
return null;
|
|
23
|
+
let path = url;
|
|
24
|
+
try {
|
|
25
|
+
path = new URL(url).pathname;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
}
|
|
29
|
+
const escapedService = OMELETTE_TURN_SERVICE.replace(/\./g, '\\.');
|
|
30
|
+
const match = path.match(new RegExp(`(?:^|/)${escapedService}/(Chat|RenewTurn|ReleaseTurn)(?:$|[/?#])`));
|
|
31
|
+
if (!match?.[1])
|
|
32
|
+
return null;
|
|
33
|
+
return TURN_RPCS.includes(match[1]) ? match[1] : null;
|
|
34
|
+
}
|
|
35
|
+
export function observedRpcPathFromUrl(url) {
|
|
36
|
+
if (!url)
|
|
37
|
+
return null;
|
|
38
|
+
let path = url;
|
|
39
|
+
try {
|
|
40
|
+
path = new URL(url).pathname;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
}
|
|
44
|
+
const serviceIdx = path.indexOf(`${OMELETTE_TURN_SERVICE}/`);
|
|
45
|
+
if (serviceIdx >= 0)
|
|
46
|
+
return path.slice(serviceIdx);
|
|
47
|
+
const idx = path.indexOf('OmeletteService/');
|
|
48
|
+
return idx >= 0 ? path.slice(idx) : null;
|
|
49
|
+
}
|
|
50
|
+
export function isTurnRpcUrl(url) {
|
|
51
|
+
return turnRpcFromUrl(url) !== null;
|
|
52
|
+
}
|
|
53
|
+
function urlFromParams(method, params) {
|
|
54
|
+
if (typeof params.requestUrl === 'string')
|
|
55
|
+
return params.requestUrl;
|
|
56
|
+
if (typeof params.url === 'string')
|
|
57
|
+
return params.url;
|
|
58
|
+
if (method === 'Network.requestWillBeSent') {
|
|
59
|
+
const req = asRec(params.request);
|
|
60
|
+
return typeof req.url === 'string' ? req.url : '';
|
|
61
|
+
}
|
|
62
|
+
if (method === 'Network.responseReceived') {
|
|
63
|
+
const resp = asRec(params.response);
|
|
64
|
+
return typeof resp.url === 'string' ? resp.url : '';
|
|
65
|
+
}
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
function isCriticalRpc(rpc) {
|
|
69
|
+
return rpc === 'Chat' || rpc === 'RenewTurn';
|
|
70
|
+
}
|
|
71
|
+
export function classifyEvent(method, rawParams, runStartTs) {
|
|
72
|
+
const params = asRec(rawParams);
|
|
73
|
+
const wallMs = eventWallMs(params);
|
|
74
|
+
if (wallMs !== null && wallMs < runStartTs)
|
|
75
|
+
return null;
|
|
76
|
+
const url = urlFromParams(method, params);
|
|
77
|
+
const rpc = turnRpcFromUrl(url);
|
|
78
|
+
if (method === 'Network.requestWillBeSent') {
|
|
79
|
+
if (rpc === 'Chat')
|
|
80
|
+
return { kind: 'chat-open', requestId: requestId(params) };
|
|
81
|
+
if (rpc === 'RenewTurn')
|
|
82
|
+
return { kind: 'heartbeat', requestId: requestId(params) };
|
|
83
|
+
if (rpc === 'ReleaseTurn')
|
|
84
|
+
return { kind: 'release', requestId: requestId(params) };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (method === 'Network.dataReceived' && rpc === 'Chat') {
|
|
88
|
+
return { kind: 'chat-chunk', requestId: requestId(params) };
|
|
89
|
+
}
|
|
90
|
+
if (method === 'Network.responseReceived') {
|
|
91
|
+
const response = asRec(params.response);
|
|
92
|
+
const status = num(response.status);
|
|
93
|
+
if (status !== null && status >= 400 && isCriticalRpc(rpc)) {
|
|
94
|
+
return { kind: 'critical-error', rpc, status };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (method === 'Network.loadingFailed' && isCriticalRpc(rpc)) {
|
|
99
|
+
return { kind: 'critical-error', rpc, status: 'failed' };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
export class RunStateObserver extends CdpSession {
|
|
104
|
+
now;
|
|
105
|
+
currentState = 'idle';
|
|
106
|
+
runStartTs = 0;
|
|
107
|
+
lastActivity = 0;
|
|
108
|
+
priorRunSignals = 0;
|
|
109
|
+
terminalResult = null;
|
|
110
|
+
watchdog = null;
|
|
111
|
+
waiters = [];
|
|
112
|
+
requestUrls = new Map();
|
|
113
|
+
observedRpcPaths = new Set();
|
|
114
|
+
signalCounts = {
|
|
115
|
+
chatOpen: 0,
|
|
116
|
+
chatChunk: 0,
|
|
117
|
+
heartbeat: 0,
|
|
118
|
+
release: 0,
|
|
119
|
+
criticalError: 0,
|
|
120
|
+
observerLost: 0
|
|
121
|
+
};
|
|
122
|
+
closeCount = 0;
|
|
123
|
+
constructor(ws, target, opts = {}) {
|
|
124
|
+
super(ws, target, opts);
|
|
125
|
+
this.now = opts.now ?? (() => Date.now());
|
|
126
|
+
}
|
|
127
|
+
static async attach(opts = {}) {
|
|
128
|
+
if (typeof WebSocket === 'undefined')
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const { ws, target } = await RunStateObserver.connectTarget(opts);
|
|
132
|
+
const observer = new RunStateObserver(ws, target, opts);
|
|
133
|
+
await observer.start();
|
|
134
|
+
return observer;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async start() {
|
|
141
|
+
await this.enableDomains();
|
|
142
|
+
}
|
|
143
|
+
beginRun() {
|
|
144
|
+
if (this.terminalResult)
|
|
145
|
+
return;
|
|
146
|
+
this.runStartTs = this.now();
|
|
147
|
+
this.lastActivity = this.runStartTs;
|
|
148
|
+
this.priorRunSignals = 0;
|
|
149
|
+
this.requestUrls.clear();
|
|
150
|
+
this.observedRpcPaths.clear();
|
|
151
|
+
this.resetSignalCounts();
|
|
152
|
+
this.currentState = 'running';
|
|
153
|
+
}
|
|
154
|
+
get state() {
|
|
155
|
+
if (this.terminalResult)
|
|
156
|
+
return this.terminalResult.terminal;
|
|
157
|
+
return this.currentState;
|
|
158
|
+
}
|
|
159
|
+
awaitTerminal({ stallMs = 25_000, hardTimeoutMs = 20 * 60_000 } = {}) {
|
|
160
|
+
if (this.terminalResult)
|
|
161
|
+
return Promise.resolve(this.terminalResult);
|
|
162
|
+
this.armWatchdog(stallMs, hardTimeoutMs);
|
|
163
|
+
this.checkSilence(stallMs, hardTimeoutMs);
|
|
164
|
+
if (this.terminalResult)
|
|
165
|
+
return Promise.resolve(this.terminalResult);
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
this.waiters.push(resolve);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
close() {
|
|
171
|
+
this.clearWatchdog();
|
|
172
|
+
if (this.closeSocket())
|
|
173
|
+
this.closeCount++;
|
|
174
|
+
}
|
|
175
|
+
signalSummary() {
|
|
176
|
+
return {
|
|
177
|
+
...this.signalCounts,
|
|
178
|
+
observedRpcPaths: [...this.observedRpcPaths].sort()
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
closeCountForTest() {
|
|
182
|
+
return this.closeCount;
|
|
183
|
+
}
|
|
184
|
+
consumeSignalForTest(signal) {
|
|
185
|
+
this.consumeSignal(signal);
|
|
186
|
+
}
|
|
187
|
+
socketGapForTest(detail = { reason: 'test' }) {
|
|
188
|
+
this.onSocketGap(detail);
|
|
189
|
+
}
|
|
190
|
+
tickForTest({ stallMs = 25_000, hardTimeoutMs = 20 * 60_000 } = {}) {
|
|
191
|
+
this.checkSilence(stallMs, hardTimeoutMs);
|
|
192
|
+
}
|
|
193
|
+
onEvent(method, rawParams, _sessionId) {
|
|
194
|
+
if (this.currentState === 'idle' && !this.terminalResult)
|
|
195
|
+
return;
|
|
196
|
+
const params = this.enrichParams(method, rawParams);
|
|
197
|
+
if (method === 'Network.responseReceived' || method === 'Network.loadingFailed') {
|
|
198
|
+
const id = requestId(params);
|
|
199
|
+
if (!id || !this.requestUrls.has(id))
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const signal = classifyEvent(method, params, this.runStartTs);
|
|
203
|
+
if (signal)
|
|
204
|
+
this.consumeSignal(signal);
|
|
205
|
+
}
|
|
206
|
+
onSocketGap(_detail) {
|
|
207
|
+
if (this.currentState === 'running' || this.currentState === 'stalled') {
|
|
208
|
+
this.consumeSignal({ kind: 'observer-lost' });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
onSocketReconnectFailed() {
|
|
212
|
+
this.consumeSignal({ kind: 'observer-lost' });
|
|
213
|
+
}
|
|
214
|
+
enrichParams(method, rawParams) {
|
|
215
|
+
const params = { ...asRec(rawParams) };
|
|
216
|
+
const id = requestId(params);
|
|
217
|
+
if (method === 'Network.requestWillBeSent' && id) {
|
|
218
|
+
const wallMs = eventWallMs(params);
|
|
219
|
+
if (wallMs !== null && wallMs < this.runStartTs)
|
|
220
|
+
return params;
|
|
221
|
+
const req = asRec(params.request);
|
|
222
|
+
const url = typeof req.url === 'string' ? req.url : '';
|
|
223
|
+
if (url) {
|
|
224
|
+
this.requestUrls.set(id, url);
|
|
225
|
+
const rpcPath = observedRpcPathFromUrl(url);
|
|
226
|
+
if (rpcPath)
|
|
227
|
+
this.observedRpcPaths.add(rpcPath);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (id && !params.requestUrl) {
|
|
231
|
+
const url = this.requestUrls.get(id);
|
|
232
|
+
if (url)
|
|
233
|
+
params.requestUrl = url;
|
|
234
|
+
}
|
|
235
|
+
return params;
|
|
236
|
+
}
|
|
237
|
+
consumeSignal(signal) {
|
|
238
|
+
if (this.terminalResult)
|
|
239
|
+
return;
|
|
240
|
+
this.countSignal(signal);
|
|
241
|
+
if (signal.kind === 'observer-lost') {
|
|
242
|
+
this.latch('observer-lost');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (this.currentState === 'idle')
|
|
246
|
+
return;
|
|
247
|
+
if (signal.kind === 'chat-open' || signal.kind === 'chat-chunk' || signal.kind === 'heartbeat') {
|
|
248
|
+
this.lastActivity = this.now();
|
|
249
|
+
this.priorRunSignals++;
|
|
250
|
+
this.currentState = 'running';
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (signal.kind === 'release') {
|
|
254
|
+
if (this.priorRunSignals === 0)
|
|
255
|
+
return;
|
|
256
|
+
this.latch('finished');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (signal.kind === 'critical-error') {
|
|
260
|
+
this.latch('blocked', `${signal.rpc} ${signal.status === 'failed' ? 'failed' : `HTTP ${signal.status}`}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
countSignal(signal) {
|
|
264
|
+
if (signal.kind === 'chat-open')
|
|
265
|
+
this.signalCounts.chatOpen++;
|
|
266
|
+
else if (signal.kind === 'chat-chunk')
|
|
267
|
+
this.signalCounts.chatChunk++;
|
|
268
|
+
else if (signal.kind === 'heartbeat')
|
|
269
|
+
this.signalCounts.heartbeat++;
|
|
270
|
+
else if (signal.kind === 'release')
|
|
271
|
+
this.signalCounts.release++;
|
|
272
|
+
else if (signal.kind === 'critical-error')
|
|
273
|
+
this.signalCounts.criticalError++;
|
|
274
|
+
else if (signal.kind === 'observer-lost')
|
|
275
|
+
this.signalCounts.observerLost++;
|
|
276
|
+
}
|
|
277
|
+
resetSignalCounts() {
|
|
278
|
+
this.signalCounts.chatOpen = 0;
|
|
279
|
+
this.signalCounts.chatChunk = 0;
|
|
280
|
+
this.signalCounts.heartbeat = 0;
|
|
281
|
+
this.signalCounts.release = 0;
|
|
282
|
+
this.signalCounts.criticalError = 0;
|
|
283
|
+
this.signalCounts.observerLost = 0;
|
|
284
|
+
}
|
|
285
|
+
checkSilence(stallMs, hardTimeoutMs) {
|
|
286
|
+
if (this.terminalResult || this.currentState === 'idle')
|
|
287
|
+
return;
|
|
288
|
+
const now = this.now();
|
|
289
|
+
const silence = now - this.lastActivity;
|
|
290
|
+
const elapsed = now - this.runStartTs;
|
|
291
|
+
if (silence > hardTimeoutMs) {
|
|
292
|
+
this.latch('timeout', `silent for ${silence}ms`);
|
|
293
|
+
}
|
|
294
|
+
else if (elapsed > hardTimeoutMs) {
|
|
295
|
+
this.latch('timeout', `exceeded ${hardTimeoutMs}ms wall-clock budget (elapsed ${elapsed}ms)`);
|
|
296
|
+
}
|
|
297
|
+
else if (silence > stallMs) {
|
|
298
|
+
this.currentState = 'stalled';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
armWatchdog(stallMs, hardTimeoutMs) {
|
|
302
|
+
if (this.watchdog)
|
|
303
|
+
return;
|
|
304
|
+
const intervalMs = Math.max(250, Math.min(1000, stallMs, hardTimeoutMs));
|
|
305
|
+
this.watchdog = setInterval(() => this.checkSilence(stallMs, hardTimeoutMs), intervalMs);
|
|
306
|
+
}
|
|
307
|
+
clearWatchdog() {
|
|
308
|
+
if (!this.watchdog)
|
|
309
|
+
return;
|
|
310
|
+
clearInterval(this.watchdog);
|
|
311
|
+
this.watchdog = null;
|
|
312
|
+
}
|
|
313
|
+
latch(terminal, reason) {
|
|
314
|
+
if (this.terminalResult)
|
|
315
|
+
return;
|
|
316
|
+
this.terminalResult = {
|
|
317
|
+
terminal,
|
|
318
|
+
elapsedMs: this.runStartTs === 0 ? 0 : Math.max(0, this.now() - this.runStartTs),
|
|
319
|
+
...(reason ? { reason } : {})
|
|
320
|
+
};
|
|
321
|
+
this.clearWatchdog();
|
|
322
|
+
this.close();
|
|
323
|
+
const waiters = this.waiters.splice(0);
|
|
324
|
+
for (const resolve of waiters)
|
|
325
|
+
resolve(this.terminalResult);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -217,6 +217,7 @@ async function main() {
|
|
|
217
217
|
sessionNav = { target: probeUrl, landedOn: '', error: e.message };
|
|
218
218
|
console.log(`[ci-health] canary navigation failed — ${e.message}; session anchors will fail loudly`);
|
|
219
219
|
}
|
|
220
|
+
process.env.DESIGNER_TURN_RPC_CANARY ??= '1';
|
|
220
221
|
sessionResults = await runHealth(browser, { phase: 'session' });
|
|
221
222
|
}
|
|
222
223
|
else {
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --import tsx
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function asRec(v) {
|
|
5
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
6
|
+
}
|
|
7
|
+
function num(v) {
|
|
8
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
9
|
+
}
|
|
10
|
+
function normalizeEndpoint(url) {
|
|
11
|
+
try {
|
|
12
|
+
const u = new URL(url);
|
|
13
|
+
const p = u.pathname.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':id').replace(/\/\d{4,}/g, '/:n');
|
|
14
|
+
return u.origin + p;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return url.slice(0, 120);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function pct(sorted, p) {
|
|
21
|
+
if (sorted.length === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
|
24
|
+
return sorted[idx] ?? 0;
|
|
25
|
+
}
|
|
26
|
+
function ms(n) {
|
|
27
|
+
return `${Math.round(n)}ms`;
|
|
28
|
+
}
|
|
29
|
+
function main() {
|
|
30
|
+
const argPath = process.argv[2];
|
|
31
|
+
if (!argPath) {
|
|
32
|
+
console.error('Usage: trace-analyze.ts <traceDir | trace.jsonl>');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const dir = argPath.endsWith('.jsonl') ? path.dirname(argPath) : argPath;
|
|
36
|
+
const jsonlPath = argPath.endsWith('.jsonl') ? argPath : path.join(dir, 'trace.jsonl');
|
|
37
|
+
const manifestPath = path.join(dir, 'manifest.json');
|
|
38
|
+
let manifest = {};
|
|
39
|
+
if (fs.existsSync(manifestPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
42
|
+
manifest = asRec(parsed);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
manifest = {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const reqs = new Map();
|
|
49
|
+
const sockets = new Map();
|
|
50
|
+
const domSamples = [];
|
|
51
|
+
const markers = [];
|
|
52
|
+
let monoToWallOffset = null;
|
|
53
|
+
let firstTs = Infinity;
|
|
54
|
+
let lastTs = 0;
|
|
55
|
+
let totalLines = 0;
|
|
56
|
+
for (const line of fs.readFileSync(jsonlPath, 'utf8').split('\n')) {
|
|
57
|
+
if (!line.trim())
|
|
58
|
+
continue;
|
|
59
|
+
totalLines++;
|
|
60
|
+
let ev;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(line);
|
|
63
|
+
ev = asRec(parsed);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const ts = num(ev.ts) ?? 0;
|
|
69
|
+
if (ts) {
|
|
70
|
+
firstTs = Math.min(firstTs, ts);
|
|
71
|
+
lastTs = Math.max(lastTs, ts);
|
|
72
|
+
}
|
|
73
|
+
if (ev.kind === 'dom-sample') {
|
|
74
|
+
domSamples.push({ ts, sample: asRec(ev.sample) });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ev.kind === 'recorder') {
|
|
78
|
+
const detail = asRec(ev.detail);
|
|
79
|
+
if (ev.event === 'marker')
|
|
80
|
+
markers.push({ ts, name: String(detail.name || ''), detail });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ev.kind !== 'cdp')
|
|
84
|
+
continue;
|
|
85
|
+
const method = String(ev.method || '');
|
|
86
|
+
const p = asRec(ev.params);
|
|
87
|
+
const requestId = typeof p.requestId === 'string' ? p.requestId : null;
|
|
88
|
+
if (method === 'Network.requestWillBeSent' && requestId) {
|
|
89
|
+
const req = asRec(p.request);
|
|
90
|
+
const mono = num(p.timestamp);
|
|
91
|
+
const wall = num(p.wallTime);
|
|
92
|
+
if (mono !== null && wall !== null && monoToWallOffset === null)
|
|
93
|
+
monoToWallOffset = wall - mono;
|
|
94
|
+
reqs.set(requestId, {
|
|
95
|
+
requestId,
|
|
96
|
+
url: String(req.url || ''),
|
|
97
|
+
method: String(req.method || 'GET'),
|
|
98
|
+
resourceType: typeof p.type === 'string' ? p.type : null,
|
|
99
|
+
mimeType: null,
|
|
100
|
+
status: null,
|
|
101
|
+
hasContentLength: false,
|
|
102
|
+
sentMono: mono,
|
|
103
|
+
sentWall: wall,
|
|
104
|
+
responseMono: null,
|
|
105
|
+
finishedMono: null,
|
|
106
|
+
failed: null,
|
|
107
|
+
fromCache: false,
|
|
108
|
+
chunks: [],
|
|
109
|
+
sseMessages: 0
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (requestId && reqs.has(requestId)) {
|
|
113
|
+
const r = reqs.get(requestId);
|
|
114
|
+
if (method === 'Network.responseReceived') {
|
|
115
|
+
const resp = asRec(p.response);
|
|
116
|
+
r.mimeType = typeof resp.mimeType === 'string' ? resp.mimeType : null;
|
|
117
|
+
r.status = num(resp.status);
|
|
118
|
+
r.responseMono = num(p.timestamp);
|
|
119
|
+
const headers = asRec(resp.headers);
|
|
120
|
+
r.hasContentLength = Object.keys(headers).some((k) => k.toLowerCase() === 'content-length');
|
|
121
|
+
}
|
|
122
|
+
else if (method === 'Network.dataReceived') {
|
|
123
|
+
r.chunks.push({
|
|
124
|
+
mono: num(p.timestamp) ?? 0,
|
|
125
|
+
bytes: num(p.dataLength) ?? 0,
|
|
126
|
+
encodedBytes: num(p.encodedDataLength) ?? 0
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
else if (method === 'Network.loadingFinished') {
|
|
130
|
+
r.finishedMono = num(p.timestamp);
|
|
131
|
+
}
|
|
132
|
+
else if (method === 'Network.loadingFailed') {
|
|
133
|
+
r.failed = String(p.errorText || 'failed') + (p.canceled === true ? ' (canceled)' : '');
|
|
134
|
+
r.finishedMono = num(p.timestamp);
|
|
135
|
+
}
|
|
136
|
+
else if (method === 'Network.requestServedFromCache') {
|
|
137
|
+
r.fromCache = true;
|
|
138
|
+
}
|
|
139
|
+
else if (method === 'Network.eventSourceMessageReceived') {
|
|
140
|
+
r.sseMessages++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (method === 'Network.webSocketCreated' && requestId) {
|
|
144
|
+
sockets.set(requestId, {
|
|
145
|
+
requestId,
|
|
146
|
+
url: String(p.url || ''),
|
|
147
|
+
framesSent: 0,
|
|
148
|
+
framesReceived: 0,
|
|
149
|
+
bytesReceived: 0,
|
|
150
|
+
closed: false
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (requestId && sockets.has(requestId)) {
|
|
154
|
+
const s = sockets.get(requestId);
|
|
155
|
+
if (method === 'Network.webSocketFrameSent')
|
|
156
|
+
s.framesSent++;
|
|
157
|
+
if (method === 'Network.webSocketFrameReceived') {
|
|
158
|
+
s.framesReceived++;
|
|
159
|
+
const resp = asRec(p.response);
|
|
160
|
+
s.bytesReceived += typeof resp.payloadData === 'string' ? resp.payloadData.length : num(resp.payloadBytes) ?? 0;
|
|
161
|
+
}
|
|
162
|
+
if (method === 'Network.webSocketClosed')
|
|
163
|
+
s.closed = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const monoToWall = (mono) => mono !== null && monoToWallOffset !== null ? (mono + monoToWallOffset) * 1000 : null;
|
|
167
|
+
const streamKind = (r) => {
|
|
168
|
+
if (r.sseMessages > 0 || r.mimeType === 'text/event-stream')
|
|
169
|
+
return 'sse';
|
|
170
|
+
const span = r.chunks.length >= 2 ? (r.chunks[r.chunks.length - 1]?.mono ?? 0) - (r.chunks[0]?.mono ?? 0) : 0;
|
|
171
|
+
if (r.chunks.length >= 3 && span > 1 && !r.hasContentLength)
|
|
172
|
+
return 'fetch-chunked';
|
|
173
|
+
return 'plain';
|
|
174
|
+
};
|
|
175
|
+
const endpoints = new Map();
|
|
176
|
+
for (const r of reqs.values()) {
|
|
177
|
+
const key = normalizeEndpoint(r.url);
|
|
178
|
+
const e = endpoints.get(key) || { hits: 0, methods: new Set(), kinds: new Set(), bytes: 0, sentWalls: [] };
|
|
179
|
+
e.hits++;
|
|
180
|
+
e.methods.add(r.method);
|
|
181
|
+
e.kinds.add(streamKind(r));
|
|
182
|
+
e.bytes += r.chunks.reduce((a, c) => a + c.encodedBytes, 0);
|
|
183
|
+
const w = monoToWall(r.sentMono);
|
|
184
|
+
if (w !== null)
|
|
185
|
+
e.sentWalls.push(w);
|
|
186
|
+
endpoints.set(key, e);
|
|
187
|
+
}
|
|
188
|
+
const iterStart = markers.find((m) => m.name === 'iterate-start')?.ts ?? null;
|
|
189
|
+
let main_ = null;
|
|
190
|
+
let mainScore = -1;
|
|
191
|
+
for (const r of reqs.values()) {
|
|
192
|
+
if (!/^https:\/\/claude\.ai\//.test(r.url))
|
|
193
|
+
continue;
|
|
194
|
+
if (r.method !== 'POST')
|
|
195
|
+
continue;
|
|
196
|
+
const span = r.responseMono !== null && r.finishedMono !== null ? r.finishedMono - r.responseMono : 0;
|
|
197
|
+
let score = span * 1000 + r.chunks.length;
|
|
198
|
+
const w = monoToWall(r.sentMono);
|
|
199
|
+
if (iterStart !== null && w !== null && w >= iterStart - 1000 && w <= iterStart + 5000)
|
|
200
|
+
score += 100_000;
|
|
201
|
+
if (score > mainScore) {
|
|
202
|
+
mainScore = score;
|
|
203
|
+
main_ = r;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const lines = [];
|
|
207
|
+
const out = (s = '') => {
|
|
208
|
+
lines.push(s);
|
|
209
|
+
};
|
|
210
|
+
const m = manifest;
|
|
211
|
+
out(`# Trace summary — ${m.scenario ?? path.basename(dir)}`);
|
|
212
|
+
out();
|
|
213
|
+
out(`- file: \`${jsonlPath}\``);
|
|
214
|
+
out(`- span: ${((lastTs - firstTs) / 1000).toFixed(1)}s | lines: ${totalLines} | requests: ${reqs.size} | sockets: ${sockets.size} | dom-samples: ${domSamples.length}`);
|
|
215
|
+
if (m.iterate)
|
|
216
|
+
out(`- iterate: failureMode=${m.iterate.failureMode} elapsed=${Math.round((m.iterate.elapsedMs ?? 0) / 1000)}s`);
|
|
217
|
+
for (const mk of markers)
|
|
218
|
+
out(`- marker \`${mk.name}\` @ +${((mk.ts - firstTs) / 1000).toFixed(1)}s`);
|
|
219
|
+
out();
|
|
220
|
+
out(`## Endpoints`);
|
|
221
|
+
out();
|
|
222
|
+
out(`| endpoint | hits | methods | kind | bytes | periodicity |`);
|
|
223
|
+
out(`|---|---|---|---|---|---|`);
|
|
224
|
+
const sortedEndpoints = [...endpoints.entries()].sort((a, b) => b[1].bytes - a[1].bytes || b[1].hits - a[1].hits);
|
|
225
|
+
for (const [ep, e] of sortedEndpoints) {
|
|
226
|
+
const walls = e.sentWalls.sort((x, y) => x - y);
|
|
227
|
+
const gaps = [];
|
|
228
|
+
for (let i = 1; i < walls.length; i++)
|
|
229
|
+
gaps.push((walls[i] ?? 0) - (walls[i - 1] ?? 0));
|
|
230
|
+
gaps.sort((x, y) => x - y);
|
|
231
|
+
const period = gaps.length >= 2 ? `~${(pct(gaps, 50) / 1000).toFixed(1)}s` : '';
|
|
232
|
+
out(`| ${ep.replace(/^https:\/\//, '')} | ${e.hits} | ${[...e.methods].join(',')} | ${[...e.kinds].join(',')} | ${e.bytes} | ${period} |`);
|
|
233
|
+
}
|
|
234
|
+
out();
|
|
235
|
+
if (main_) {
|
|
236
|
+
const kind = streamKind(main_);
|
|
237
|
+
out(`## Main generation request`);
|
|
238
|
+
out();
|
|
239
|
+
out(`**Verdict: generation streams via \`${kind}\` at \`${main_.method} ${normalizeEndpoint(main_.url)}\`** (status ${main_.status}, mime ${main_.mimeType})`);
|
|
240
|
+
out();
|
|
241
|
+
const t0 = main_.sentMono ?? 0;
|
|
242
|
+
out(`- request sent: t0${iterStart !== null && monoToWall(t0) !== null ? ` (+${((monoToWall(t0) - iterStart) / 1000).toFixed(1)}s after iterate-start)` : ''}`);
|
|
243
|
+
if (main_.responseMono !== null)
|
|
244
|
+
out(`- response headers: t0+${ms((main_.responseMono - t0) * 1000)}`);
|
|
245
|
+
if (main_.finishedMono !== null)
|
|
246
|
+
out(`- loading finished: t0+${((main_.finishedMono - t0)).toFixed(1)}s${main_.failed ? ` (FAILED: ${main_.failed})` : ''}`);
|
|
247
|
+
out(`- chunks: ${main_.chunks.length} | total encoded bytes: ${main_.chunks.reduce((a, c) => a + c.encodedBytes, 0)}`);
|
|
248
|
+
out();
|
|
249
|
+
const gaps = [];
|
|
250
|
+
for (let i = 1; i < main_.chunks.length; i++) {
|
|
251
|
+
gaps.push(((main_.chunks[i]?.mono ?? 0) - (main_.chunks[i - 1]?.mono ?? 0)) * 1000);
|
|
252
|
+
}
|
|
253
|
+
if (gaps.length) {
|
|
254
|
+
const sorted = [...gaps].sort((a, b) => a - b);
|
|
255
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
256
|
+
out(`### Inter-chunk gaps (candidate STALLED thresholds)`);
|
|
257
|
+
out();
|
|
258
|
+
out(`max ${ms(sorted[sorted.length - 1] ?? 0)} | p95 ${ms(pct(sorted, 95))} | p50 ${ms(pct(sorted, 50))} | mean ${ms(mean)}`);
|
|
259
|
+
out();
|
|
260
|
+
out(`→ a STALLED detector needs its no-chunk timeout comfortably above max (e.g. ${Math.ceil(((sorted[sorted.length - 1] ?? 0) * 3) / 1000)}s).`);
|
|
261
|
+
out();
|
|
262
|
+
}
|
|
263
|
+
const lastChunkWall = monoToWall(main_.chunks[main_.chunks.length - 1]?.mono ?? null);
|
|
264
|
+
const finishedWall = monoToWall(main_.finishedMono);
|
|
265
|
+
out(`### Network-quiet vs DOM-stable`);
|
|
266
|
+
out();
|
|
267
|
+
if (lastChunkWall !== null)
|
|
268
|
+
out(`- last chunk of main request: +${((lastChunkWall - firstTs) / 1000).toFixed(1)}s`);
|
|
269
|
+
if (finishedWall !== null)
|
|
270
|
+
out(`- main request finished: +${((finishedWall - firstTs) / 1000).toFixed(1)}s`);
|
|
271
|
+
let lastTurnChange = null;
|
|
272
|
+
let lastIframeChange = null;
|
|
273
|
+
let prevTurns = null;
|
|
274
|
+
let prevIframe = null;
|
|
275
|
+
for (const d of domSamples) {
|
|
276
|
+
if (d.sample.chatTurnCount !== prevTurns) {
|
|
277
|
+
if (prevTurns !== null)
|
|
278
|
+
lastTurnChange = d.ts;
|
|
279
|
+
prevTurns = d.sample.chatTurnCount;
|
|
280
|
+
}
|
|
281
|
+
if (d.sample.iframeSrc !== prevIframe) {
|
|
282
|
+
if (prevIframe !== null)
|
|
283
|
+
lastIframeChange = d.ts;
|
|
284
|
+
prevIframe = d.sample.iframeSrc;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (lastTurnChange !== null)
|
|
288
|
+
out(`- last chatTurnCount change (dom): +${((lastTurnChange - firstTs) / 1000).toFixed(1)}s`);
|
|
289
|
+
if (lastIframeChange !== null)
|
|
290
|
+
out(`- last iframeSrc change (dom): +${((lastIframeChange - firstTs) / 1000).toFixed(1)}s`);
|
|
291
|
+
const stopSeen = domSamples.filter((d) => d.sample.stopProbe).length;
|
|
292
|
+
out(`- dom-samples with a visible stop/cancel button: ${stopSeen}/${domSamples.length}`);
|
|
293
|
+
const probes = domSamples.map((d) => d.sample.stopProbe).filter(Boolean);
|
|
294
|
+
if (probes.length)
|
|
295
|
+
out(`- stop-button probe: \`${JSON.stringify(probes[0]).slice(0, 300)}\``);
|
|
296
|
+
out();
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
out(`## Main generation request`);
|
|
300
|
+
out();
|
|
301
|
+
out(`(none identified — expected for idle/quota traces)`);
|
|
302
|
+
out();
|
|
303
|
+
}
|
|
304
|
+
const byRpc = (name) => [...reqs.values()]
|
|
305
|
+
.filter((r) => r.url.includes(`OmeletteService/${name}`))
|
|
306
|
+
.sort((a, b) => (a.sentMono ?? 0) - (b.sentMono ?? 0));
|
|
307
|
+
const renews = byRpc('RenewTurn');
|
|
308
|
+
const releases = byRpc('ReleaseTurn');
|
|
309
|
+
const chats = byRpc('Chat');
|
|
310
|
+
if (renews.length || releases.length || chats.length) {
|
|
311
|
+
out(`## Turn lifecycle (OmeletteService)`);
|
|
312
|
+
out();
|
|
313
|
+
if (chats.length) {
|
|
314
|
+
out(`Chat segments: ${chats.length}`);
|
|
315
|
+
for (const c of chats.slice(0, 40)) {
|
|
316
|
+
const w = monoToWall(c.sentMono);
|
|
317
|
+
const dur = c.responseMono !== null && c.finishedMono !== null ? c.finishedMono - c.responseMono : 0;
|
|
318
|
+
out(`- +${w !== null ? ((w - firstTs) / 1000).toFixed(1) : '?'}s dur=${dur.toFixed(1)}s chunks=${c.chunks.length} bytes=${c.chunks.reduce((a, ch) => a + ch.encodedBytes, 0)}${c.failed ? ` FAILED: ${c.failed}` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
out();
|
|
321
|
+
}
|
|
322
|
+
if (renews.length >= 2) {
|
|
323
|
+
const walls = renews.map((r) => monoToWall(r.sentMono)).filter((w) => w !== null);
|
|
324
|
+
const gaps = [];
|
|
325
|
+
for (let i = 1; i < walls.length; i++)
|
|
326
|
+
gaps.push((walls[i] ?? 0) - (walls[i - 1] ?? 0));
|
|
327
|
+
gaps.sort((a, b) => a - b);
|
|
328
|
+
const firstW = walls[0] ?? 0;
|
|
329
|
+
const lastW = walls[walls.length - 1] ?? 0;
|
|
330
|
+
out(`RenewTurn: ${renews.length} calls, median gap ${(pct(gaps, 50) / 1000).toFixed(1)}s, first +${((firstW - firstTs) / 1000).toFixed(1)}s, last +${((lastW - firstTs) / 1000).toFixed(1)}s`);
|
|
331
|
+
}
|
|
332
|
+
for (const rel of releases) {
|
|
333
|
+
const w = monoToWall(rel.sentMono);
|
|
334
|
+
out(`ReleaseTurn: +${w !== null ? ((w - firstTs) / 1000).toFixed(1) : '?'}s ← discrete FINISHED candidate`);
|
|
335
|
+
}
|
|
336
|
+
const iterDone = markers.find((mk) => mk.name === 'iterate-done')?.ts ?? null;
|
|
337
|
+
if (releases.length && iterDone !== null) {
|
|
338
|
+
const w = monoToWall(releases[releases.length - 1]?.sentMono ?? null);
|
|
339
|
+
if (w !== null)
|
|
340
|
+
out(`→ ReleaseTurn led the controller's HTML-stability verdict (iterate-done) by ${((iterDone - w) / 1000).toFixed(1)}s`);
|
|
341
|
+
}
|
|
342
|
+
out();
|
|
343
|
+
}
|
|
344
|
+
if (sockets.size) {
|
|
345
|
+
out(`## WebSockets`);
|
|
346
|
+
out();
|
|
347
|
+
for (const s of sockets.values()) {
|
|
348
|
+
out(`- ${s.url.slice(0, 100)} — frames sent ${s.framesSent} / recv ${s.framesReceived}, recv bytes ${s.bytesReceived}${s.closed ? ', closed' : ''}`);
|
|
349
|
+
}
|
|
350
|
+
out();
|
|
351
|
+
}
|
|
352
|
+
const failed = [...reqs.values()].filter((r) => r.failed);
|
|
353
|
+
if (failed.length) {
|
|
354
|
+
out(`## Failed requests`);
|
|
355
|
+
out();
|
|
356
|
+
for (const r of failed)
|
|
357
|
+
out(`- ${r.method} ${normalizeEndpoint(r.url)} — ${r.failed}`);
|
|
358
|
+
out();
|
|
359
|
+
}
|
|
360
|
+
const md = lines.join('\n');
|
|
361
|
+
const outPath = path.join(dir, 'summary.md');
|
|
362
|
+
fs.writeFileSync(outPath, md);
|
|
363
|
+
console.log(md);
|
|
364
|
+
console.log(`\n(written to ${outPath})`);
|
|
365
|
+
}
|
|
366
|
+
main();
|