Open History to see the last commit
1import type { ProviderDriverKind } from "@t3tools/contracts";
2
3import type {
4 HttpTransferMeasurement,
5 WebSocketTransferTotals,
6} from "./NetworkTransferMeasurement.integration.ts";
7import {
8 TRANSFER_HISTORY_MCP_RESULT_BYTES,
9 TRANSFER_HISTORY_TOOLS_PER_TURN,
10 TRANSFER_HISTORY_TURN_COUNT,
11 TRANSFER_MEASURED_MCP_RESULT_BYTES,
12 TRANSFER_MEASURED_TOOLS,
13} from "./fixtures/transferBudget.ts";
14import {
15 LONG_TURN_WORK_ROWS,
16 SHELL_IDLE_SUBAGENTS,
17 SHELL_SETTLED_THREADS,
18 STALE_TODO_LIVE_ROWS,
19 STALE_TODO_SETTLED_RUNS,
20 STALE_TODO_SUBAGENTS_PER_RUN,
21} from "./TransferBudgetV2Fixture.integration.ts";
22
23export interface WebSocketCatchUpMeasurement extends WebSocketTransferTotals {
24 readonly mode: "replay" | "snapshot";
25}
26
27export interface TimelineHttpMeasurement {
28 readonly http: HttpTransferMeasurement;
29 readonly rows: number;
30}
31
32const STALE_TODO_RECORD_KINDS = [
33 "nodes",
34 "subagents",
35 "plans",
36 "providerTurns",
37 "checkpoints",
38] as const;
39type StaleTodoRecordCounts = Readonly<Record<(typeof STALE_TODO_RECORD_KINDS)[number], number>>;
40
41/** One new socket, read from the subscribe call until the subscription caught up. */
42export interface SocketOpenMeasurement {
43 readonly totals: WebSocketTransferTotals;
44}
45
46export interface TransferBudgetRun {
47 readonly provider: ProviderDriverKind;
48 readonly threadSnapshot: HttpTransferMeasurement;
49 readonly measuredTurnWebSocket: WebSocketTransferTotals;
50 readonly shellSnapshot: HttpTransferMeasurement;
51 readonly measuredTurnShellWebSocket: WebSocketTransferTotals;
52 readonly measuredTurnSecondClientWebSocket: WebSocketTransferTotals;
53 readonly reconnectThread: WebSocketCatchUpMeasurement;
54 readonly reconnectShell: WebSocketCatchUpMeasurement;
55 readonly measuredTurnSqlStatements: number;
56 readonly reconnectSqlStatements: number;
57 /** A thread resumed from the cursor it held before one ordinary turn. */
58 readonly shortTurnResume: SocketOpenMeasurement;
59 /** The long-turn thread while its last turn runs: the bounded snapshot, then one older page. */
60 readonly longTurnRunningColdOpen: TimelineHttpMeasurement;
61 readonly longTurnRunningHistoryPage: TimelineHttpMeasurement;
62 /** The same reads as an older client that only sends `compactTurnItems`, to show the savings. */
63 readonly longTurnRunningColdOpenLegacy: TimelineHttpMeasurement;
64 readonly longTurnRunningHistoryPageLegacy: TimelineHttpMeasurement;
65 /** The same thread once the turn finished, over HTTP (web) and over the socket alone (mobile). */
66 readonly longTurnColdOpen: TimelineHttpMeasurement;
67 readonly longTurnSocketColdOpen: SocketOpenMeasurement & { readonly rows: number };
68 /** The first page of the finished turn's deferred work, loaded when its group is expanded. */
69 readonly longTurnWorkPage: TimelineHttpMeasurement;
70 /** A thread resumed from the cursor of its own last event. */
71 readonly idleThreadResume: SocketOpenMeasurement;
72 /** The cold-connect shell snapshot after the settled-thread fleet is written. */
73 readonly fleetShell: TimelineHttpMeasurement;
74 readonly fleetShellLegacy: TimelineHttpMeasurement;
75 /** The shell subscription a cold connect opens from that snapshot's cursor. */
76 readonly fleetShellSubscribe: SocketOpenMeasurement;
77 /** A client that holds that snapshot's rows asks the HTTP route whether they still resume. */
78 readonly fleetShellResume: TimelineHttpMeasurement;
79 /** The same question from a cursor too old to replay: the route answers with the snapshot. */
80 readonly staleShellReload: TimelineHttpMeasurement;
81 /** A shell resumed over the socket from that old cursor, by a client that reloads over HTTP. */
82 readonly staleShellResume: SocketOpenMeasurement;
83 /**
84 * The compact bounded snapshot of the long thread whose finished runs left todo lists open,
85 * with the count of each record kind that rides along with its rows.
86 */
87 readonly staleTodoColdOpen: TimelineHttpMeasurement & {
88 readonly records: StaleTodoRecordCounts;
89 };
90}
91
92interface ProviderTransferBudget {
93 readonly totalWireBytes: number;
94 readonly threadSnapshotWireBytes: number;
95 readonly measuredTurnWebSocketWireBytes: number;
96 readonly measuredTurnWebSocketDecodedBytes: number;
97 readonly measuredTurnWebSocketMessages: number;
98}
99
100// The measured turn decodes to about 22 KB; without live body dedupe its repeated answer makes it 26 KB.
101const TRANSFER_BUDGET = {
102 totalWireBytes: 7_000,
103 threadSnapshotWireBytes: 5_000,
104 measuredTurnWebSocketWireBytes: 2_000,
105 measuredTurnWebSocketDecodedBytes: 25_000,
106 measuredTurnWebSocketMessages: 8,
107} satisfies ProviderTransferBudget;
108
109const TRANSFER_BUDGETS: Readonly<Record<string, ProviderTransferBudget>> = {
110 codex: TRANSFER_BUDGET,
111 claudeAgent: TRANSFER_BUDGET,
112};
113
114// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About 15% above the
115// observed 95 KB history page, so a page without the ID table (130 KB) or row budgets fails.
116// Kept apart from TRANSFER_BUDGET because the PR comment validates its keys.
117const LONG_TURN_PAGE_BUDGET = {
118 decodedBytes: 110_000,
119 rows: 75,
120};
121
122// About 7% above the observed 290 KB. The window holds 75 rows, but every message of a running
123// turn rides along with it: 196 KB here. Without body dedupe or the ID table it is 354 KB.
124const LONG_TURN_RUNNING_BUDGET = {
125 decodedBytes: 310_000,
126 rows: 75,
127};
128
129// About 7% above the observed 393 KB. A finished turn defers its 770 work rows and stays whole:
130// 151 rows of messages, checkpoints and subagents, on both transports.
131const LONG_TURN_FINISHED_BUDGET = {
132 decodedBytes: 420_000,
133 rows: 151,
134};
135
136// About 6% above the observed 193 KB: one older page of work, twice the cold-open row budget.
137const LONG_TURN_WORK_PAGE_BUDGET = {
138 decodedBytes: 205_000,
139 rows: 150,
140};
141
142const SOCKET_BUDGETS = {
143 // One ordinary turn replays in 11 KB. With its tool bodies it is over 900 KB.
144 shortTurnResume: { decodedBytes: 13_000, messages: 2 },
145 longTurnColdOpen: { decodedBytes: LONG_TURN_FINISHED_BUDGET.decodedBytes, messages: 2 },
146 // These three send no rows. A snapshot in their place is 393 KB for the thread, 42 KB for the shell.
147 idleThreadResume: { decodedBytes: 200, messages: 1 },
148 // A subscribe that does not name the snapshot's project identities gets the project again: 851 B.
149 fleetShellSubscribe: { decodedBytes: 200, messages: 1 },
150 staleShellResume: { decodedBytes: 400, messages: 2 },
151};
152
153// About 30% above the observed 42 KB. Without the shell opt-ins it carries all 142 rows (217 KB).
154const FLEET_SHELL_BUDGET = {
155 decodedBytes: 55_000,
156 rows: 27,
157};
158
159// The answer is `{"resumable":true}`. A snapshot in its place is 42 KB.
160const FLEET_SHELL_RESUME_BUDGET = {
161 decodedBytes: 100,
162 rows: 0,
163};
164
165// About 14% above the observed 75 KB. The window holds the working run only: 2 nodes, 2 subagents,
166// 1 plan, 1 provider turn and the last finished turn's checkpoint. When finished runs ride along
167// it holds 82 nodes, 82 subagents, 41 plans, 41 provider turns, 40 checkpoints and 740 KB.
168// The working run's ready handoff ships without its 256 KB history; with it the open is 339 KB.
169const STALE_TODO_BUDGET = {
170 decodedBytes: 85_000,
171 rows: 75,
172 records: {
173 nodes: 4,
174 subagents: 4,
175 plans: 2,
176 providerTurns: 2,
177 checkpoints: 2,
178 } satisfies StaleTodoRecordCounts,
179};
180
181function totalWireBytes(run: TransferBudgetRun): number {
182 return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes;
183}
184
185function serverEgressWireBytes(run: TransferBudgetRun): number {
186 return (
187 run.measuredTurnWebSocket.wireBytes +
188 run.measuredTurnShellWebSocket.wireBytes +
189 run.measuredTurnSecondClientWebSocket.wireBytes
190 );
191}
192
193function observedTransfer(run: TransferBudgetRun) {
194 return {
195 totalWireBytes: totalWireBytes(run),
196 threadSnapshotWireBytes: run.threadSnapshot.wireBytes,
197 threadSnapshotDecodedBytes: run.threadSnapshot.decodedBodyBytes,
198 measuredTurnWebSocketWireBytes: run.measuredTurnWebSocket.wireBytes,
199 measuredTurnWebSocketDecodedBytes: run.measuredTurnWebSocket.decodedBytes,
200 measuredTurnWebSocketMessages: run.measuredTurnWebSocket.messages,
201 };
202}
203
204export function formatTransferBudgetResult(runs: ReadonlyArray<TransferBudgetRun>): string {
205 const providers = Object.fromEntries(
206 runs.flatMap((run) => {
207 const ceiling = TRANSFER_BUDGETS[run.provider];
208 return ceiling ? [[run.provider, { observed: observedTransfer(run), ceiling }]] : [];
209 }),
210 );
211
212 return `${JSON.stringify(
213 {
214 schemaVersion: 1,
215 scenario: {
216 id: "thread-transfer-v2",
217 historyTurns: TRANSFER_HISTORY_TURN_COUNT,
218 historyCommandToolsPerTurn: TRANSFER_HISTORY_TOOLS_PER_TURN,
219 historyMcpResultBytes: TRANSFER_HISTORY_MCP_RESULT_BYTES,
220 measuredCommandTools: TRANSFER_MEASURED_TOOLS,
221 measuredMcpResultBytes: TRANSFER_MEASURED_MCP_RESULT_BYTES,
222 },
223 providers,
224 },
225 null,
226 2,
227 )}\n`;
228}
229
230function formatBytes(bytes: number): string {
231 if (bytes < 1_024) return `${bytes} B`;
232 if (bytes >= 1_024 * 1_024) {
233 return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB (${bytes.toLocaleString("en-US")} B)`;
234 }
235 return `${(bytes / 1_024).toFixed(1)} KiB (${bytes.toLocaleString("en-US")} B)`;
236}
237
238function row(
239 provider: ProviderDriverKind,
240 phase: string,
241 metric: string,
242 observed: number,
243 maximum: number,
244 format: (value: number) => string = formatBytes,
245): string {
246 const status = observed <= maximum ? "PASS" : "FAIL";
247 return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`;
248}
249
250// The 50 ms shell batching makes transfer, reconnect and SQL figures timing-dependent.
251function infoRow(
252 provider: ProviderDriverKind,
253 phase: string,
254 metric: string,
255 observed: number,
256 format: (value: number) => string = formatBytes,
257): string {
258 return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | none | INFO |`;
259}
260
261function webSocketRows(
262 provider: ProviderDriverKind,
263 phase: string,
264 totals: WebSocketTransferTotals,
265): string[] {
266 return [
267 infoRow(provider, phase, "WebSocket wire", totals.wireBytes),
268 infoRow(provider, phase, "WebSocket decoded", totals.decodedBytes),
269 infoRow(provider, phase, "WebSocket messages", totals.messages, String),
270 ];
271}
272
273interface ScenarioRow {
274 readonly phase: string;
275 readonly metric: string;
276 readonly unit: "bytes" | "count";
277 readonly observed: number;
278 /** Absent on figures that only inform: wire bytes, and what an older client receives. */
279 readonly maximum: number | undefined;
280}
281
282function httpRows(
283 phase: string,
284 measured: TimelineHttpMeasurement,
285 budget?: { readonly decodedBytes: number; readonly rows: number },
286): ScenarioRow[] {
287 return [
288 {
289 phase,
290 metric: "HTTP wire",
291 unit: "bytes",
292 observed: measured.http.wireBytes,
293 maximum: undefined,
294 },
295 {
296 phase,
297 metric: "HTTP decoded",
298 unit: "bytes",
299 observed: measured.http.decodedBodyBytes,
300 maximum: budget?.decodedBytes,
301 },
302 { phase, metric: "rows", unit: "count", observed: measured.rows, maximum: budget?.rows },
303 ];
304}
305
306function socketRows(
307 phase: string,
308 { totals }: SocketOpenMeasurement,
309 budget: { readonly decodedBytes: number; readonly messages: number },
310): ScenarioRow[] {
311 return [
312 {
313 phase,
314 metric: "WebSocket wire",
315 unit: "bytes",
316 observed: totals.wireBytes,
317 maximum: undefined,
318 },
319 {
320 phase,
321 metric: "WebSocket decoded",
322 unit: "bytes",
323 observed: totals.decodedBytes,
324 maximum: budget.decodedBytes,
325 },
326 {
327 phase,
328 metric: "WebSocket messages",
329 unit: "count",
330 observed: totals.messages,
331 maximum: budget.messages,
332 },
333 ];
334}
335
336/**
337 * Every scenario figure beyond the thread totals. The violations, the report table and the PR
338 * comment's scenario file all read this list, so a row added here reaches all three. The PR
339 * comment only accepts letters, digits, spaces, commas and hyphens in `phase` and `metric`.
340 */
341function scenarioRows(run: TransferBudgetRun): ScenarioRow[] {
342 return [
343 ...socketRows("short turn resume", run.shortTurnResume, SOCKET_BUDGETS.shortTurnResume),
344 ...httpRows(
345 "long turn running, cold open",
346 run.longTurnRunningColdOpen,
347 LONG_TURN_RUNNING_BUDGET,
348 ),
349 ...httpRows("long turn running, cold open, older client", run.longTurnRunningColdOpenLegacy),
350 ...httpRows(
351 "long turn running, history page",
352 run.longTurnRunningHistoryPage,
353 LONG_TURN_PAGE_BUDGET,
354 ),
355 ...httpRows(
356 "long turn running, history page, older client",
357 run.longTurnRunningHistoryPageLegacy,
358 ),
359 ...httpRows("long turn finished, cold open", run.longTurnColdOpen, LONG_TURN_FINISHED_BUDGET),
360 ...socketRows(
361 "long turn finished, socket cold open",
362 run.longTurnSocketColdOpen,
363 SOCKET_BUDGETS.longTurnColdOpen,
364 ),
365 {
366 phase: "long turn finished, socket cold open",
367 metric: "rows",
368 unit: "count",
369 observed: run.longTurnSocketColdOpen.rows,
370 maximum: LONG_TURN_FINISHED_BUDGET.rows,
371 },
372 ...httpRows("long turn finished, work page", run.longTurnWorkPage, LONG_TURN_WORK_PAGE_BUDGET),
373 ...socketRows("idle thread resume", run.idleThreadResume, SOCKET_BUDGETS.idleThreadResume),
374 ...httpRows("fleet shell snapshot", run.fleetShell, FLEET_SHELL_BUDGET),
375 ...httpRows("fleet shell snapshot, older client", run.fleetShellLegacy),
376 ...socketRows(
377 "fleet shell subscribe",
378 run.fleetShellSubscribe,
379 SOCKET_BUDGETS.fleetShellSubscribe,
380 ),
381 ...httpRows("fleet shell resume check", run.fleetShellResume, FLEET_SHELL_RESUME_BUDGET),
382 ...httpRows("stale shell reload", run.staleShellReload, FLEET_SHELL_BUDGET),
383 ...socketRows("stale shell resume", run.staleShellResume, SOCKET_BUDGETS.staleShellResume),
384 ...httpRows("stale todo list cold open", run.staleTodoColdOpen, STALE_TODO_BUDGET),
385 ...STALE_TODO_RECORD_KINDS.map((kind): ScenarioRow => ({
386 phase: "stale todo list cold open",
387 metric: kind,
388 unit: "count",
389 observed: run.staleTodoColdOpen.records[kind],
390 maximum: STALE_TODO_BUDGET.records[kind],
391 })),
392 ];
393}
394
395/** The budgeted scenario figures, for the PR comment to compare with `main`. */
396export function formatTransferBudgetScenarios(runs: ReadonlyArray<TransferBudgetRun>): string {
397 const providers = Object.fromEntries(
398 runs.map((run) => [
399 run.provider,
400 scenarioRows(run).flatMap(({ maximum, ...row }) =>
401 maximum === undefined ? [] : [{ ...row, ceiling: maximum }],
402 ),
403 ]),
404 );
405 return `${JSON.stringify({ schemaVersion: 1, providers }, null, 2)}\n`;
406}
407
408export function transferBudgetViolations(runs: ReadonlyArray<TransferBudgetRun>): string[] {
409 const violations: string[] = [];
410 for (const run of runs) {
411 const budget = TRANSFER_BUDGETS[run.provider];
412 if (!budget) {
413 violations.push(`${run.provider}: no transfer budget is configured`);
414 continue;
415 }
416 const checks = [
417 ["total thread wire bytes", totalWireBytes(run), budget.totalWireBytes],
418 ["thread snapshot wire bytes", run.threadSnapshot.wireBytes, budget.threadSnapshotWireBytes],
419 [
420 "measured-turn WebSocket wire bytes",
421 run.measuredTurnWebSocket.wireBytes,
422 budget.measuredTurnWebSocketWireBytes,
423 ],
424 [
425 "measured-turn WebSocket decoded bytes",
426 run.measuredTurnWebSocket.decodedBytes,
427 budget.measuredTurnWebSocketDecodedBytes,
428 ],
429 [
430 "measured-turn WebSocket messages",
431 run.measuredTurnWebSocket.messages,
432 budget.measuredTurnWebSocketMessages,
433 ],
434 ...scenarioRows(run).flatMap(({ phase, metric, observed, maximum }) =>
435 maximum === undefined ? [] : [[`${phase} ${metric}`, observed, maximum] as const],
436 ),
437 ] as const;
438 for (const [metric, observed, maximum] of checks) {
439 if (observed > maximum) {
440 violations.push(`${run.provider}: ${metric} was ${observed}, maximum ${maximum}`);
441 }
442 }
443 }
444 return violations;
445}
446
447export function formatTransferBudgetReport(runs: ReadonlyArray<TransferBudgetRun>): string {
448 const lines = [
449 "# T3 Code thread transfer budget",
450 "",
451 "Wire values are thread data bytes read from local HTTP and WebSocket sockets. HTTP includes response headers; WebSocket measurement starts after the resumed thread subscription synchronizes. TCP/IP, TLS framing, and the WebSocket upgrade are excluded. WebSocket permessage-deflate is negotiated.",
452 "The measured turn is observed on three sockets at once: one with only the thread subscription (the capped rows), one with only the shell subscription, and a second client holding both. Server egress is the sum of the three. After the turn the second client disconnects and new sockets resubscribe from the cursor it held before the turn, which is the cursor a backgrounded phone would hold. SQL statements are the SQL client's per-statement spans, counted across V2 persistence and the HTTP/WebSocket handlers.",
453 `Scenario: ${TRANSFER_HISTORY_TURN_COUNT} historical turns with ${TRANSFER_HISTORY_TOOLS_PER_TURN} command tools and one retained ${formatBytes(TRANSFER_HISTORY_MCP_RESULT_BYTES)} MCP result each, followed by one measured turn with ${TRANSFER_MEASURED_TOOLS} command tools and a retained ${formatBytes(TRANSFER_MEASURED_MCP_RESULT_BYTES)} MCP result. Synthetic V2 domain events exercise persistence, wire projection, and production HTTP/subscription handlers; this does not measure provider adapter ingestion. Payloads contain no user data.`,
454 `The long turn rows read a second thread whose last user turn holds ${LONG_TURN_WORK_ROWS} rows of reasoning, commands, file changes, MCP calls, progress messages, checkpoints, and subagents. While that turn runs, web and mobile load the compact bounded snapshot and then one older page. Once it finishes, its work is deferred: the cold open is read over HTTP as web does and over the socket alone as mobile does, then the first page of the deferred work is loaded as an expanded work group does.`,
455 "The resume rows open a new socket from a cursor the client already holds: the first thread after one more ordinary turn, the long-turn thread with nothing new, and the shell from a cursor too old to replay. A client that holds shell rows first names their cursor to the HTTP shell route: the resume check row is the answer that keeps the rows, and the stale shell reload row is the snapshot that replaces them.",
456 `The fleet shell rows read the cold-connect shell after ${SHELL_SETTLED_THREADS} settled threads and ${SHELL_IDLE_SUBAGENTS} idle subagent children are added, then subscribe from that snapshot's cursor and name its project identities, so the project's repository identity is not sent again. Budgeted reads send the opt-ins current clients send; "older client" rows show the same read without the opt-ins added since row budgets.`,
457 `The stale todo list rows open a third thread the same way: ${STALE_TODO_SETTLED_RUNS} finished runs, then one run still at work with ${STALE_TODO_LIVE_ROWS} rows. Every run left its todo list open and has ${STALE_TODO_SUBAGENTS_PER_RUN} finished subagents, a provider turn and, once finished, a checkpoint. The record counts must not grow with the number of finished runs.`,
458 "",
459 "| Provider | Total thread wire | Budget | Result |",
460 "| --- | ---: | ---: | --- |",
461 ...runs.flatMap((run) => {
462 const budget = TRANSFER_BUDGETS[run.provider];
463 if (!budget) return [];
464 const observed = observedTransfer(run).totalWireBytes;
465 return [
466 `| ${run.provider} | ${formatBytes(observed)} | ${formatBytes(budget.totalWireBytes)} | ${observed <= budget.totalWireBytes ? "PASS" : "FAIL"} |`,
467 ];
468 }),
469 "",
470 "## Detailed measurements",
471 "",
472 "| Provider | Phase | Metric | Observed | Budget | Result |",
473 "| --- | --- | --- | ---: | ---: | --- |",
474 ];
475
476 for (const run of runs) {
477 const budget = TRANSFER_BUDGETS[run.provider];
478 if (!budget) continue;
479 lines.push(
480 row(
481 run.provider,
482 "thread snapshot",
483 "HTTP wire",
484 run.threadSnapshot.wireBytes,
485 budget.threadSnapshotWireBytes,
486 ),
487 row(
488 run.provider,
489 "measured turn",
490 "WebSocket wire",
491 run.measuredTurnWebSocket.wireBytes,
492 budget.measuredTurnWebSocketWireBytes,
493 ),
494 row(
495 run.provider,
496 "measured turn",
497 "WebSocket decoded",
498 run.measuredTurnWebSocket.decodedBytes,
499 budget.measuredTurnWebSocketDecodedBytes,
500 ),
501 row(
502 run.provider,
503 "measured turn",
504 "WebSocket messages",
505 run.measuredTurnWebSocket.messages,
506 budget.measuredTurnWebSocketMessages,
507 String,
508 ),
509 infoRow(run.provider, "shell snapshot", "HTTP wire", run.shellSnapshot.wireBytes),
510 ...webSocketRows(run.provider, "measured turn, shell", run.measuredTurnShellWebSocket),
511 ...webSocketRows(
512 run.provider,
513 "measured turn, second client",
514 run.measuredTurnSecondClientWebSocket,
515 ),
516 infoRow(run.provider, "measured turn", "server egress wire", serverEgressWireBytes(run)),
517 infoRow(
518 run.provider,
519 "measured turn",
520 "SQL statements",
521 run.measuredTurnSqlStatements,
522 String,
523 ),
524 ...webSocketRows(
525 run.provider,
526 `reconnect, thread (${run.reconnectThread.mode})`,
527 run.reconnectThread,
528 ),
529 ...webSocketRows(
530 run.provider,
531 `reconnect, shell (${run.reconnectShell.mode})`,
532 run.reconnectShell,
533 ),
534 infoRow(run.provider, "reconnect", "SQL statements", run.reconnectSqlStatements, String),
535 ...scenarioRows(run).map(({ phase, metric, unit, observed, maximum }) => {
536 const format = unit === "bytes" ? formatBytes : String;
537 return maximum === undefined
538 ? infoRow(run.provider, phase, metric, observed, format)
539 : row(run.provider, phase, metric, observed, maximum, format);
540 }),
541 );
542 }
543
544 lines.push("", "## Compression diagnostics", "");
545 for (const run of runs) {
546 lines.push(
547 `- ${run.provider}: thread snapshot ${formatBytes(run.threadSnapshot.decodedBodyBytes)} decoded to ${formatBytes(run.threadSnapshot.encodedBodyBytes)} gzip.`,
548 `- ${run.provider}: shell snapshot ${formatBytes(run.shellSnapshot.decodedBodyBytes)} decoded to ${formatBytes(run.shellSnapshot.encodedBodyBytes)} gzip.`,
549 );
550 }
551
552 return `${lines.join("\n")}\n`;
553}