@akira-tl/forgerelay 0.8.4 → 0.8.6

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +10 -10
  3. package/dist/activity/audit-store.js +44 -6
  4. package/dist/activity/mcp-query-tools.js +53 -36
  5. package/dist/activity/query-service.js +41 -11
  6. package/dist/cli.js +16 -17
  7. package/dist/composite-activity.js +124 -33
  8. package/dist/config.js +8 -13
  9. package/dist/lsp/test-support/server-fixture.js +7 -7
  10. package/dist/process-sessions.js +2 -2
  11. package/dist/remote-auth.js +11 -1
  12. package/dist/remote-mcp-connection-pool.js +90 -0
  13. package/dist/remote-transport.js +28 -14
  14. package/dist/remote-workspace-relay.js +203 -45
  15. package/dist/server.js +134 -64
  16. package/dist/skills.js +1 -1
  17. package/dist/subagents/profiles.js +1 -2
  18. package/dist/subagents/providers/adapters/pi.js +2 -2
  19. package/dist/subagents/providers/availability.js +2 -2
  20. package/dist/subagents/providers/path.js +4 -4
  21. package/dist/ui/.vite/manifest.json +32 -32
  22. package/dist/ui/activity-panel-app.html +3 -3
  23. package/dist/ui/assets/{activity-panel-app-E1ju2dqI.js → activity-panel-app-CUAN6zyW.js} +1 -1
  24. package/dist/ui/assets/{heavy-payload-CeW-n9w5.js → heavy-payload-CgzrutLm.js} +1 -1
  25. package/dist/ui/assets/{review-payload-B9CO298v.js → review-payload-BrLbezbq.js} +1 -1
  26. package/dist/ui/assets/{scrollbar-C2twAENW.js → scrollbar-CbhpdW05.js} +1 -1
  27. package/dist/ui/assets/workspace-app-Bhj96tsR.js +1 -0
  28. package/dist/ui/assets/{workspace-app-CwbJnb_w.js → workspace-app-CxwJuZyS.js} +1 -1
  29. package/dist/ui/assets/{workspace-app-BztEvZIC.js → workspace-app-D6UR0AFl.js} +3 -3
  30. package/dist/ui/assets/workspace-app-ldjBmCJR.css +1 -0
  31. package/dist/ui/assets/workspace-lifecycle-app-Cqfhx9pV.js +1 -0
  32. package/dist/ui/workspace-app.html +4 -4
  33. package/dist/ui/workspace-lifecycle-app.html +4 -4
  34. package/dist/user-config.js +3 -19
  35. package/dist/workspace-presentation.js +69 -0
  36. package/docs/agent-profile-schema.md +4 -9
  37. package/docs/artifact-exchange.md +2 -1
  38. package/docs/chatgpt-coding-workflow.md +9 -9
  39. package/docs/configuration.md +20 -29
  40. package/docs/gotchas.md +10 -7
  41. package/docs/roadmap.md +6 -3
  42. package/docs/security.md +3 -2
  43. package/docs/setup.md +8 -5
  44. package/docs/versioning.md +1 -1
  45. package/package.json +6 -2
  46. package/scripts/debug/accept.mjs +7 -1
  47. package/scripts/debug/relay-accept.mjs +889 -0
  48. package/scripts/debug/runtime.mjs +0 -4
  49. package/scripts/debug/runtime.test.mjs +0 -2
  50. package/scripts/debug/traffic/run.sh +5 -0
  51. package/scripts/debug/traffic/traffic-audit.mjs +907 -0
  52. package/scripts/release/push-ready.mjs +124 -0
  53. package/scripts/release/push-ready.test.mjs +108 -0
  54. package/scripts/release/release-gate.test.mjs +1 -0
  55. package/scripts/release-proof.mjs +16 -1
  56. package/scripts/wiki/sync.mjs +246 -0
  57. package/dist/ui/assets/workspace-app-YnUST8IP.css +0 -1
  58. package/dist/ui/assets/workspace-app-rKuhdae8.js +0 -1
  59. package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +0 -1
@@ -0,0 +1,907 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import {
6
+ appendFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { setTimeout as delay } from "node:timers/promises";
15
+ import { activityRefreshDelayMs } from "../../../src/ui/activity/model.ts";
16
+ import { debugRoot, repoRoot } from "../runtime.mjs";
17
+
18
+ const mode = process.argv[2] ?? "all";
19
+ if (!["all", "local", "relay"].includes(mode)) {
20
+ throw new Error("Usage: scripts/debug/traffic/run.sh [all|local|relay]");
21
+ }
22
+
23
+ const trafficRoot = resolve(debugRoot, "traffic-audit");
24
+ const findings = [];
25
+ let highTrafficFinding = false;
26
+
27
+ await assertPortsFree([7677, 7678]);
28
+ rmSync(trafficRoot, { recursive: true, force: true });
29
+ mkdirSync(trafficRoot, { recursive: true });
30
+
31
+ if (mode === "all" || mode === "local") await auditLocalTraffic();
32
+ if (mode === "all" || mode === "relay") await auditRelayTraffic();
33
+
34
+ console.log("\n=== ForgeRelay traffic audit ===");
35
+ for (const finding of findings) {
36
+ console.log(`${finding.level.padEnd(5)} ${finding.name}`);
37
+ for (const [key, value] of Object.entries(finding.metrics)) {
38
+ console.log(` ${key}: ${value}`);
39
+ }
40
+ }
41
+ console.log(`\nArtifacts: ${trafficRoot}`);
42
+
43
+ if (highTrafficFinding) {
44
+ console.error("\nTRAFFIC_AUDIT_RED: current implementation exceeds at least one conservative traffic budget.");
45
+ process.exitCode = 2;
46
+ } else {
47
+ console.log("\nTRAFFIC_AUDIT_GREEN: no configured traffic budget was exceeded.");
48
+ }
49
+
50
+ async function auditLocalTraffic() {
51
+ const root = join(trafficRoot, "local");
52
+ const configDir = join(root, "config");
53
+ const stateDir = join(root, "state");
54
+ const worktreeRoot = join(root, "worktrees");
55
+ const logPath = join(root, "server.jsonl");
56
+ mkdirSync(configDir, { recursive: true });
57
+ const ownerToken = randomBytes(32).toString("base64url");
58
+ writeAuthFile(configDir, ownerToken, "traffic-audit-local");
59
+
60
+ const env = instanceEnv({
61
+ port: 7677,
62
+ baseUrl: "http://127.0.0.1:7677",
63
+ configDir,
64
+ stateDir,
65
+ worktreeRoot,
66
+ allowedRoot: repoRoot,
67
+ ownerToken,
68
+ widgets: "full",
69
+ });
70
+ const server = spawnServer(env, "Local 7677", logPath);
71
+
72
+ try {
73
+ await waitForHealth(server, "http://127.0.0.1:7677");
74
+ const host = authorizeHost("http://127.0.0.1:7677", "http://127.0.0.1:7677/mcp", ownerToken);
75
+ const sessionId = initializeSession("http://127.0.0.1:7677/mcp", host.accessToken, 1, "traffic-audit-local");
76
+ const meta = { "openai/session": "traffic-audit-local-conversation" };
77
+ let id = 10;
78
+
79
+ const full = callToolMeasured("http://127.0.0.1:7677/mcp", host.accessToken, sessionId, id++, "open_workspace", {
80
+ path: repoRoot,
81
+ context: "full",
82
+ }, meta);
83
+ const workspaceId = full.result.structuredContent.workspaceId;
84
+ const auto = callToolMeasured("http://127.0.0.1:7677/mcp", host.accessToken, sessionId, id++, "open_workspace", {
85
+ workspaceId,
86
+ context: "auto",
87
+ }, meta);
88
+ const panel = callToolMeasured("http://127.0.0.1:7677/mcp", host.accessToken, sessionId, id++, "activity_panel", {
89
+ workspaceId,
90
+ }, meta);
91
+ const turnId = panel.result.structuredContent.turnId;
92
+
93
+ const fullBytes = full.http.sizeDownload;
94
+ const autoBytes = auto.http.sizeDownload;
95
+ const panelBytes = panel.http.sizeDownload;
96
+ const autoRatio = fullBytes === 0 ? 0 : autoBytes / fullBytes;
97
+ const panelRatio = fullBytes === 0 ? 0 : panelBytes / fullBytes;
98
+ const autoCardBytes = jsonBytes(auto.result._meta?.card);
99
+ const autoStructuredBytes = jsonBytes(auto.result.structuredContent);
100
+ const panelWorkspace = panel.result._meta?.["forgerelay/activityPanelWorkspace"];
101
+ const panelWorkspaceBytes = jsonBytes(panelWorkspace);
102
+ const panelStructuredWorkspaceBytes = jsonBytes(
103
+ panel.result.structuredContent?.["forgerelay/activityPanelWorkspace"],
104
+ );
105
+ addFinding(
106
+ autoRatio >= 0.5 ? "HIGH" : autoRatio >= 0.2 ? "MED" : "LOW",
107
+ "open_workspace context=auto wire reuse",
108
+ {
109
+ "full response body": formatBytes(fullBytes),
110
+ "auto response body": formatBytes(autoBytes),
111
+ "auto/full": autoRatio.toFixed(2),
112
+ "auto _meta.card": formatBytes(autoCardBytes),
113
+ "auto structuredContent": formatBytes(autoStructuredBytes),
114
+ "activity_panel response body": formatBytes(panelBytes),
115
+ "panel/full": panelRatio.toFixed(2),
116
+ "panel workspace copy in _meta": formatBytes(panelWorkspaceBytes),
117
+ "panel workspace copy in structuredContent": formatBytes(panelStructuredWorkspaceBytes),
118
+ },
119
+ autoRatio >= 0.5,
120
+ );
121
+
122
+ const bash = callToolMeasured("http://127.0.0.1:7677/mcp", host.accessToken, sessionId, id++, "bash", {
123
+ workspaceId,
124
+ command: `${JSON.stringify(process.execPath)} -e "let n=0; const chunk='x'.repeat(262144); const t=setInterval(()=>{process.stdout.write(chunk); n += 1; if(n===5){clearInterval(t); setTimeout(()=>process.exit(0), 1200)}}, 1000)"`,
125
+ yieldTimeMs: 0,
126
+ maxOutputTokens: 1000,
127
+ }, meta);
128
+ const outputId = bash.result.structuredContent.outputId;
129
+ assert.equal(typeof outputId, "string");
130
+
131
+ const outputPolls = [];
132
+ let outputCursor;
133
+ for (let poll = 0; poll < 5; poll += 1) {
134
+ await delay(1100);
135
+ const response = callToolMeasured(
136
+ "http://127.0.0.1:7677/mcp",
137
+ host.accessToken,
138
+ sessionId,
139
+ id++,
140
+ "activity_output",
141
+ {
142
+ turnId,
143
+ outputId,
144
+ ...(outputCursor !== undefined ? { cursor: outputCursor } : {}),
145
+ },
146
+ meta,
147
+ );
148
+ const output = response.result.structuredContent.output;
149
+ const nextCursor = response.result.structuredContent.cursor;
150
+ if (Number.isInteger(nextCursor) && nextCursor >= 0) outputCursor = nextCursor;
151
+ outputPolls.push({
152
+ bodyBytes: response.http.sizeDownload,
153
+ outputBytes: Buffer.byteLength(output ?? "", "utf8"),
154
+ cursor: nextCursor,
155
+ status: response.result.structuredContent.status,
156
+ });
157
+ }
158
+ await delay(1500);
159
+
160
+ const totalOutputResponseBytes = outputPolls.reduce((sum, item) => sum + item.bodyBytes, 0);
161
+ const finalOutputBytes = 5 * 262_144;
162
+ const outputAmplification = finalOutputBytes === 0 ? 0 : totalOutputResponseBytes / finalOutputBytes;
163
+ addFinding(
164
+ outputAmplification >= 2 ? "HIGH" : outputAmplification >= 1.25 ? "MED" : "LOW",
165
+ "activity_output cumulative retransmission",
166
+ {
167
+ polls: String(outputPolls.length),
168
+ "final durable output": formatBytes(finalOutputBytes),
169
+ "downloaded response bodies": formatBytes(totalOutputResponseBytes),
170
+ "response/final-output amplification": `${outputAmplification.toFixed(2)}x`,
171
+ "per-poll output bytes": outputPolls.map((item) => formatBytes(item.outputBytes)).join(" -> "),
172
+ "per-poll cursors": outputPolls.map((item) => String(item.cursor ?? "missing")).join(" -> "),
173
+ },
174
+ outputAmplification >= 2,
175
+ );
176
+
177
+ const changedSnapshot = callToolMeasured(
178
+ "http://127.0.0.1:7677/mcp",
179
+ host.accessToken,
180
+ sessionId,
181
+ id++,
182
+ "activity_snapshot",
183
+ { turnId },
184
+ meta,
185
+ );
186
+ const revision = changedSnapshot.result.structuredContent.revision;
187
+ const state = changedSnapshot.result.structuredContent.state;
188
+ const stateStructured = changedSnapshot.result.structuredContent;
189
+ const stateOnlyRegression = Object.hasOwn(stateStructured, "activities")
190
+ || (changedSnapshot.result.content?.length ?? 0) !== 0;
191
+ const explicitIndex = callToolMeasured(
192
+ "http://127.0.0.1:7677/mcp",
193
+ host.accessToken,
194
+ sessionId,
195
+ id++,
196
+ "activity_index",
197
+ { turnId },
198
+ meta,
199
+ );
200
+ addFinding(
201
+ stateOnlyRegression ? "HIGH" : "LOW",
202
+ "Activity Panel state/index request tiers",
203
+ {
204
+ "state response body": formatBytes(changedSnapshot.http.sizeDownload),
205
+ "state carries Activity rows": String(Object.hasOwn(stateStructured, "activities")),
206
+ "state natural-language content items": String(changedSnapshot.result.content?.length ?? 0),
207
+ "explicit index response body": formatBytes(explicitIndex.http.sizeDownload),
208
+ "explicit index Activity rows": String(explicitIndex.result.structuredContent.activities?.length ?? 0),
209
+ note: "Collapsed/default refresh uses state only; Activity rows are an explicit expanded-panel request.",
210
+ },
211
+ stateOnlyRegression,
212
+ );
213
+ const unchanged = [];
214
+ for (let index = 0; index < 10; index += 1) {
215
+ unchanged.push(callToolMeasured(
216
+ "http://127.0.0.1:7677/mcp",
217
+ host.accessToken,
218
+ sessionId,
219
+ id++,
220
+ "activity_snapshot",
221
+ { turnId, knownRevision: revision },
222
+ meta,
223
+ ));
224
+ }
225
+ const averageSnapshotBytes = unchanged.reduce((sum, item) => sum + item.http.sizeDownload, 0) / unchanged.length;
226
+ const averageSnapshotHttpBytes = unchanged.reduce(
227
+ (sum, item) => sum + item.http.sizeRequest + item.http.sizeHeader + item.http.sizeDownload,
228
+ 0,
229
+ ) / unchanged.length;
230
+ const hourlySnapshotHttpBytes = averageSnapshotHttpBytes * 3600;
231
+ const workingBackoff = [0, 1, 2, 3].map((count) => activityRefreshDelayMs("working", count, true));
232
+ const terminalDelay = activityRefreshDelayMs("done", 0, true);
233
+ const hiddenDelay = activityRefreshDelayMs("working", 0, false);
234
+ const pollingRegression = JSON.stringify(workingBackoff) !== JSON.stringify([1_000, 2_000, 5_000, 10_000])
235
+ || terminalDelay !== null
236
+ || hiddenDelay !== null;
237
+ addFinding(
238
+ pollingRegression ? "HIGH" : "LOW",
239
+ "Activity Panel adaptive polling policy",
240
+ {
241
+ "turn state after Bash completion": String(state),
242
+ "unchanged response body avg": formatBytes(averageSnapshotBytes),
243
+ "unchanged HTTP avg": formatBytes(averageSnapshotHttpBytes),
244
+ "working unchanged delays": workingBackoff.map((value) => `${value}ms`).join(" -> "),
245
+ "terminal next poll": terminalDelay === null ? "stopped" : `${terminalDelay}ms`,
246
+ "hidden next poll": hiddenDelay === null ? "stopped" : `${hiddenDelay}ms`,
247
+ "legacy 1Hz HTTP cost avoided": `${formatBytes(hourlySnapshotHttpBytes)}/hour per live Panel`,
248
+ },
249
+ pollingRegression,
250
+ );
251
+
252
+ const assetFinding = auditActivityAssets();
253
+ addFinding("LOW", "Activity Panel initial static assets", assetFinding, false);
254
+ } finally {
255
+ await stopServer(server);
256
+ }
257
+ }
258
+
259
+ async function auditRelayTraffic() {
260
+ const root = join(trafficRoot, "relay");
261
+ const gatewayBaseUrl = "http://127.0.0.1:7677";
262
+ const executionBaseUrl = "http://127.0.0.1:7678";
263
+ const gatewayMcpUrl = `${gatewayBaseUrl}/mcp`;
264
+ const gatewayConfigDir = join(root, "gateway", "config");
265
+ const gatewayStateDir = join(root, "gateway", "state");
266
+ const gatewayWorktreeRoot = join(root, "gateway", "worktrees");
267
+ const gatewayProjectRoot = join(root, "gateway-projects");
268
+ const executionConfigDir = join(root, "execution", "config");
269
+ const executionStateDir = join(root, "execution", "state");
270
+ const executionWorktreeRoot = join(root, "execution", "worktrees");
271
+ const executionProjectRoot = join(root, "execution-projects");
272
+ const executionCheckout = join(executionProjectRoot, "checkout");
273
+ const gatewayLog = join(root, "gateway.jsonl");
274
+ const executionLog = join(root, "execution.jsonl");
275
+ const gatewayOwnerToken = randomBytes(32).toString("base64url");
276
+ const executionOwnerToken = randomBytes(32).toString("base64url");
277
+
278
+ mkdirSync(gatewayProjectRoot, { recursive: true });
279
+ mkdirSync(executionCheckout, { recursive: true });
280
+ writeFileSync(join(executionCheckout, "sentinel.txt"), "relay traffic audit\n");
281
+ writeAuthFile(gatewayConfigDir, gatewayOwnerToken, "traffic-audit-gateway");
282
+ writeAuthFile(executionConfigDir, executionOwnerToken, "traffic-audit-execution");
283
+
284
+ const executionEnv = instanceEnv({
285
+ port: 7678,
286
+ baseUrl: executionBaseUrl,
287
+ configDir: executionConfigDir,
288
+ stateDir: executionStateDir,
289
+ worktreeRoot: executionWorktreeRoot,
290
+ allowedRoot: executionProjectRoot,
291
+ ownerToken: executionOwnerToken,
292
+ widgets: "off",
293
+ });
294
+ const gatewayEnv = instanceEnv({
295
+ port: 7677,
296
+ baseUrl: gatewayBaseUrl,
297
+ configDir: gatewayConfigDir,
298
+ stateDir: gatewayStateDir,
299
+ worktreeRoot: gatewayWorktreeRoot,
300
+ allowedRoot: gatewayProjectRoot,
301
+ ownerToken: gatewayOwnerToken,
302
+ widgets: "off",
303
+ });
304
+
305
+ const execution = spawnServer(executionEnv, "Execution 7678", executionLog);
306
+ let gateway;
307
+ try {
308
+ await waitForHealth(execution, executionBaseUrl);
309
+ const authenticated = runCli(
310
+ ["auth", "127.0.0.1:7678", "--token", executionOwnerToken, "--alias", "execution"],
311
+ { ...cleanProductEnv(), FORGERELAY_CONFIG_DIR: gatewayConfigDir },
312
+ );
313
+ assert.equal(authenticated.status, 0, authenticated.stderr || authenticated.stdout);
314
+
315
+ gateway = spawnServer(gatewayEnv, "Gateway 7677", gatewayLog);
316
+ await waitForHealth(gateway, gatewayBaseUrl);
317
+ const host = authorizeHost(gatewayBaseUrl, gatewayMcpUrl, gatewayOwnerToken);
318
+ const sessionId = initializeSession(gatewayMcpUrl, host.accessToken, 1, "traffic-audit-relay");
319
+ const meta = { "openai/session": "traffic-audit-relay-conversation" };
320
+ let id = 100;
321
+
322
+ const opened = callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "open_workspace", {
323
+ path: executionCheckout,
324
+ relay: "execution",
325
+ context: "none",
326
+ }, meta);
327
+ const workspaceId = opened.result.structuredContent.workspaceId;
328
+ const panel = callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "activity_panel", { workspaceId }, meta);
329
+ const turnId = panel.result.structuredContent.turnId;
330
+ callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "read", {
331
+ workspaceId,
332
+ path: "sentinel.txt",
333
+ }, meta);
334
+
335
+ await delay(100);
336
+ const before = requestLogEntries(executionLog).length;
337
+ const beforeLog = jsonLogEntries(executionLog).length;
338
+ const snapshots = [];
339
+ let knownRevision;
340
+ for (let index = 0; index < 3; index += 1) {
341
+ const snapshot = callToolMeasured(
342
+ gatewayMcpUrl,
343
+ host.accessToken,
344
+ sessionId,
345
+ id++,
346
+ "activity_snapshot",
347
+ { turnId, ...(knownRevision === undefined ? {} : { knownRevision }) },
348
+ meta,
349
+ );
350
+ knownRevision = snapshot.result.structuredContent.revision;
351
+ snapshots.push(snapshot);
352
+ }
353
+ await delay(150);
354
+ const afterEntries = requestLogEntries(executionLog).slice(before);
355
+ const snapshotLogs = jsonLogEntries(executionLog).slice(beforeLog);
356
+ const mcpEntries = afterEntries.filter((entry) => entry.path === "/mcp");
357
+ const methods = countBy(mcpEntries, (entry) => entry.method ?? "?");
358
+ const executionRequestBodyBytes = mcpEntries.reduce((sum, entry) => {
359
+ const value = Number(entry.contentLength ?? 0);
360
+ return sum + (Number.isFinite(value) ? value : 0);
361
+ }, 0);
362
+ const clientDownload = snapshots.reduce((sum, item) => sum + item.http.sizeDownload, 0);
363
+ const relaySessionsCreated = snapshotLogs.filter(
364
+ (entry) => entry.event === "mcp_transport_session_created",
365
+ ).length;
366
+ const relayConnectionChurn = relaySessionsCreated > 0 || mcpEntries.length > snapshots.length;
367
+ addFinding(
368
+ relayConnectionChurn ? "HIGH" : "LOW",
369
+ "Relay activity_snapshot connection churn (7677 -> 7678)",
370
+ {
371
+ "Gateway snapshots": String(snapshots.length),
372
+ "Execution /mcp HTTP requests": String(mcpEntries.length),
373
+ "Execution requests per Gateway snapshot": (mcpEntries.length / snapshots.length).toFixed(2),
374
+ "Execution methods": JSON.stringify(methods),
375
+ "Execution request bodies": formatBytes(executionRequestBodyBytes),
376
+ "Host <- Gateway snapshot bodies": formatBytes(clientDownload),
377
+ "Execution transport sessions created": String(relaySessionsCreated),
378
+ },
379
+ relayConnectionChurn,
380
+ );
381
+
382
+ const compositeMeta = { "openai/session": "traffic-audit-composite-conversation" };
383
+ const composite = callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "open_workspace", {
384
+ kind: "composite",
385
+ name: "Traffic audit composite",
386
+ }, compositeMeta);
387
+ const compositeWorkspaceId = composite.result.structuredContent.workspaceId;
388
+ callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "open_workspace", {
389
+ action: "member",
390
+ workspaceId: compositeWorkspaceId,
391
+ memberAction: "add",
392
+ member: {
393
+ name: "remote",
394
+ purpose: "measure Relay Activity fanout",
395
+ workspaceId,
396
+ },
397
+ }, compositeMeta);
398
+ const compositePanel = callToolMeasured(
399
+ gatewayMcpUrl,
400
+ host.accessToken,
401
+ sessionId,
402
+ id++,
403
+ "activity_panel",
404
+ { workspaceId: compositeWorkspaceId },
405
+ compositeMeta,
406
+ );
407
+ const compositeTurnId = compositePanel.result.structuredContent.turnId;
408
+ callToolMeasured(gatewayMcpUrl, host.accessToken, sessionId, id++, "read", {
409
+ workspaceId: compositeWorkspaceId,
410
+ member: "remote",
411
+ path: "sentinel.txt",
412
+ }, compositeMeta);
413
+ await delay(100);
414
+
415
+ const compositeLogStart = jsonLogEntries(executionLog).length;
416
+ const compositeFirst = callToolMeasured(
417
+ gatewayMcpUrl,
418
+ host.accessToken,
419
+ sessionId,
420
+ id++,
421
+ "activity_snapshot",
422
+ { turnId: compositeTurnId },
423
+ compositeMeta,
424
+ );
425
+ const compositeRevision = compositeFirst.result.structuredContent.revision;
426
+ const compositeSecond = callToolMeasured(
427
+ gatewayMcpUrl,
428
+ host.accessToken,
429
+ sessionId,
430
+ id++,
431
+ "activity_snapshot",
432
+ { turnId: compositeTurnId, knownRevision: compositeRevision },
433
+ compositeMeta,
434
+ );
435
+ await delay(150);
436
+ const compositeLogs = jsonLogEntries(executionLog).slice(compositeLogStart);
437
+ const remoteSnapshotRequests = requestBodiesForRpc(compositeLogs, "activity_snapshot");
438
+ const remoteSnapshotCalls = compositeLogs.filter(
439
+ (entry) => entry.event === "activity_snapshot_call",
440
+ );
441
+ const repeatedMemberReadsUseRevision = remoteSnapshotCalls.length < 2 || remoteSnapshotCalls
442
+ .slice(1)
443
+ .every((entry) => Number.isInteger(entry.knownRevision));
444
+ const compositeHttpRequests = compositeLogs.filter(
445
+ (entry) => entry.event === "http_request" && entry.path === "/mcp",
446
+ );
447
+ const compositeFullReadRegression =
448
+ compositeSecond.result.structuredContent.changed === false && !repeatedMemberReadsUseRevision;
449
+ addFinding(
450
+ compositeFullReadRegression ? "HIGH" : "LOW",
451
+ "Composite member snapshot delta reuse",
452
+ {
453
+ "Gateway second snapshot changed": String(compositeSecond.result.structuredContent.changed),
454
+ "Execution activity_snapshot tool calls": String(remoteSnapshotRequests.length),
455
+ "Execution activity_snapshot known revisions": remoteSnapshotCalls
456
+ .map((entry) => String(entry.knownRevision ?? "none"))
457
+ .join(" -> "),
458
+ "Execution activity_snapshot request body sizes": remoteSnapshotRequests.map((value) => `${value} B`).join(" -> "),
459
+ "Execution /mcp HTTP requests for 2 Composite snapshots": String(compositeHttpRequests.length),
460
+ note: "Repeated member state reads must carry the member revision or be skipped entirely.",
461
+ },
462
+ compositeFullReadRegression,
463
+ );
464
+
465
+ const compositeIndexLogStart = jsonLogEntries(executionLog).length;
466
+ const compositeIndexFirst = callToolMeasured(
467
+ gatewayMcpUrl,
468
+ host.accessToken,
469
+ sessionId,
470
+ id++,
471
+ "activity_index",
472
+ { turnId: compositeTurnId },
473
+ compositeMeta,
474
+ );
475
+ const compositeIndexRevision = compositeIndexFirst.result.structuredContent.revision;
476
+ const compositeIndexSecond = callToolMeasured(
477
+ gatewayMcpUrl,
478
+ host.accessToken,
479
+ sessionId,
480
+ id++,
481
+ "activity_index",
482
+ { turnId: compositeTurnId, knownRevision: compositeIndexRevision },
483
+ compositeMeta,
484
+ );
485
+ await delay(150);
486
+ const compositeIndexLogs = jsonLogEntries(executionLog).slice(compositeIndexLogStart);
487
+ const remoteIndexRequests = requestBodiesForRpc(compositeIndexLogs, "activity_index");
488
+ const remoteIndexCalls = compositeIndexLogs.filter(
489
+ (entry) => entry.event === "activity_index_call",
490
+ );
491
+ const repeatedIndexReadsUseRevision = remoteIndexCalls.length < 2 || remoteIndexCalls
492
+ .slice(1)
493
+ .every((entry) => Number.isInteger(entry.knownRevision));
494
+ const compositeIndexRegression =
495
+ compositeIndexSecond.result.structuredContent.changed !== false || !repeatedIndexReadsUseRevision;
496
+ addFinding(
497
+ compositeIndexRegression ? "HIGH" : "LOW",
498
+ "Composite member Activity index delta reuse",
499
+ {
500
+ "Gateway first index rows": String(compositeIndexFirst.result.structuredContent.activities?.length ?? 0),
501
+ "Gateway second index changed": String(compositeIndexSecond.result.structuredContent.changed),
502
+ "Gateway second index rows": String(compositeIndexSecond.result.structuredContent.activities?.length ?? 0),
503
+ "Execution activity_index tool calls": String(remoteIndexRequests.length),
504
+ "Execution activity_index known revisions": remoteIndexCalls
505
+ .map((entry) => String(entry.knownRevision ?? "none"))
506
+ .join(" -> "),
507
+ "Execution activity_index request body sizes": remoteIndexRequests.map((value) => `${value} B`).join(" -> "),
508
+ note: "Expanded-panel member index reads must reuse member revisions instead of retransmitting unchanged rows.",
509
+ },
510
+ compositeIndexRegression,
511
+ );
512
+ } finally {
513
+ if (gateway) await stopServer(gateway);
514
+ await stopServer(execution);
515
+ }
516
+ }
517
+
518
+ function auditActivityAssets() {
519
+ const manifestPath = join(repoRoot, "dist", "ui", ".vite", "manifest.json");
520
+ if (!existsSync(manifestPath)) {
521
+ return { status: "dist/ui manifest missing; run npm run build:app to measure assets" };
522
+ }
523
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
524
+ const entry = manifest["activity-panel-app.html"];
525
+ if (!entry) return { status: "activity-panel-app.html missing from manifest" };
526
+ const files = new Set();
527
+ collectManifestFiles(manifest, "activity-panel-app.html", files);
528
+ const bytes = [...files].reduce((sum, file) => sum + readFileSync(join(repoRoot, "dist", "ui", file)).byteLength, 0);
529
+ return {
530
+ files: String(files.size),
531
+ "uncompressed bytes": formatBytes(bytes),
532
+ note: "content-hashed assets are served immutable for one year",
533
+ };
534
+ }
535
+
536
+ function collectManifestFiles(manifest, key, files) {
537
+ const entry = manifest[key];
538
+ if (!entry) return;
539
+ if (entry.file) files.add(entry.file);
540
+ for (const css of entry.css ?? []) files.add(css);
541
+ for (const imported of entry.imports ?? []) collectManifestFiles(manifest, imported, files);
542
+ }
543
+
544
+ function addFinding(level, name, metrics, red) {
545
+ findings.push({ level, name, metrics });
546
+ if (red) highTrafficFinding = true;
547
+ }
548
+
549
+ function cleanProductEnv() {
550
+ return Object.fromEntries(Object.entries(process.env).filter(([name]) =>
551
+ name !== "HOST"
552
+ && name !== "PORT"
553
+ && !name.startsWith("FORGERELAY_")
554
+ ));
555
+ }
556
+
557
+ function instanceEnv({ port, baseUrl, configDir, stateDir, worktreeRoot, allowedRoot, ownerToken, widgets }) {
558
+ return {
559
+ ...cleanProductEnv(),
560
+ HOST: "127.0.0.1",
561
+ PORT: String(port),
562
+ FORGERELAY_CONFIG_DIR: configDir,
563
+ FORGERELAY_PUBLIC_BASE_URL: baseUrl,
564
+ FORGERELAY_ALLOWED_ROOTS: allowedRoot,
565
+ FORGERELAY_STATE_DIR: stateDir,
566
+ FORGERELAY_WORKTREE_ROOT: worktreeRoot,
567
+ FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
568
+ FORGERELAY_TOOL_MODE: "full",
569
+ FORGERELAY_WIDGETS: widgets,
570
+ FORGERELAY_SKILLS: "1",
571
+ FORGERELAY_SUBAGENTS: "0",
572
+ FORGERELAY_ARTIFACTS: "0",
573
+ FORGERELAY_LOG_LEVEL: "debug",
574
+ FORGERELAY_LOG_FORMAT: "json",
575
+ FORGERELAY_LOG_REQUESTS: "1",
576
+ FORGERELAY_LOG_ASSETS: "1",
577
+ FORGERELAY_LOG_TOOL_CALLS: "0",
578
+ };
579
+ }
580
+
581
+ function writeAuthFile(configDir, ownerToken, instanceId) {
582
+ mkdirSync(configDir, { recursive: true });
583
+ writeFileSync(join(configDir, "auth.json"), `${JSON.stringify({ ownerToken, instanceId }, null, 2)}\n`, { mode: 0o600 });
584
+ }
585
+
586
+ function spawnServer(env, label, logPath) {
587
+ mkdirSync(resolve(logPath, ".."), { recursive: true });
588
+ writeFileSync(logPath, "");
589
+ const child = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
590
+ cwd: repoRoot,
591
+ env,
592
+ stdio: ["ignore", "pipe", "pipe"],
593
+ });
594
+ const capture = (chunk) => appendFileSync(logPath, chunk);
595
+ child.stdout.on("data", capture);
596
+ child.stderr.on("data", capture);
597
+ child.once("exit", (code, signal) => {
598
+ if (code && code !== 0) console.error(`${label} exited with code ${code}${signal ? ` (${signal})` : ""}`);
599
+ });
600
+ return child;
601
+ }
602
+
603
+ function runCli(args, env) {
604
+ return spawnSync(process.execPath, ["--import", "tsx", "src/cli.ts", ...args], {
605
+ cwd: repoRoot,
606
+ env,
607
+ encoding: "utf8",
608
+ });
609
+ }
610
+
611
+ function authorizeHost(baseUrl, mcpUrl, ownerToken) {
612
+ const metadata = jsonRequest(`${baseUrl}/.well-known/oauth-authorization-server`);
613
+ assert.equal(metadata.status, 200);
614
+ const redirectUri = `${baseUrl}/debug/traffic-audit-callback`;
615
+ const registration = jsonRequest(metadata.json.registration_endpoint, {
616
+ method: "POST",
617
+ body: JSON.stringify({
618
+ client_name: "ForgeRelay traffic audit",
619
+ redirect_uris: [redirectUri],
620
+ token_endpoint_auth_method: "none",
621
+ grant_types: ["authorization_code", "refresh_token"],
622
+ response_types: ["code"],
623
+ }),
624
+ headers: { "content-type": "application/json" },
625
+ });
626
+ assert.equal(registration.status, 201);
627
+
628
+ const verifier = randomBytes(32).toString("base64url");
629
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
630
+ const authorization = curlRequest({
631
+ method: "POST",
632
+ url: metadata.json.authorization_endpoint,
633
+ headers: { "content-type": "application/x-www-form-urlencoded" },
634
+ body: new URLSearchParams({
635
+ response_type: "code",
636
+ client_id: registration.json.client_id,
637
+ redirect_uri: redirectUri,
638
+ code_challenge: challenge,
639
+ code_challenge_method: "S256",
640
+ scope: "devspace",
641
+ resource: mcpUrl,
642
+ state: "traffic-audit",
643
+ owner_token: ownerToken,
644
+ }).toString(),
645
+ });
646
+ assert.equal(authorization.status, 302);
647
+ const redirect = new URL(authorization.headers.get("location"));
648
+ const code = redirect.searchParams.get("code");
649
+ assert.ok(code);
650
+
651
+ const token = jsonRequest(metadata.json.token_endpoint, {
652
+ method: "POST",
653
+ headers: { "content-type": "application/x-www-form-urlencoded" },
654
+ body: new URLSearchParams({
655
+ grant_type: "authorization_code",
656
+ client_id: registration.json.client_id,
657
+ code,
658
+ redirect_uri: redirectUri,
659
+ code_verifier: verifier,
660
+ resource: mcpUrl,
661
+ }).toString(),
662
+ });
663
+ assert.equal(token.status, 200);
664
+ assert.ok(token.json.access_token);
665
+ return { accessToken: token.json.access_token };
666
+ }
667
+
668
+ function initializeSession(mcpUrl, accessToken, id, clientName) {
669
+ const initialized = mcpRequest(mcpUrl, accessToken, undefined, {
670
+ jsonrpc: "2.0",
671
+ id,
672
+ method: "initialize",
673
+ params: {
674
+ protocolVersion: "2025-06-18",
675
+ capabilities: {
676
+ extensions: {
677
+ "io.modelcontextprotocol/ui": {
678
+ mimeTypes: ["text/html;profile=mcp-app"],
679
+ },
680
+ },
681
+ },
682
+ clientInfo: { name: clientName, version: "1.0.0" },
683
+ },
684
+ });
685
+ const sessionId = initialized.response.headers.get("mcp-session-id");
686
+ assert.ok(sessionId);
687
+ const notification = curlRequest({
688
+ method: "POST",
689
+ url: mcpUrl,
690
+ headers: mcpHeaders(accessToken, sessionId),
691
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
692
+ });
693
+ assert.equal(notification.status, 202);
694
+ return sessionId;
695
+ }
696
+
697
+ function callToolMeasured(mcpUrl, accessToken, sessionId, id, name, args, meta) {
698
+ const request = {
699
+ jsonrpc: "2.0",
700
+ id,
701
+ method: "tools/call",
702
+ params: {
703
+ name,
704
+ arguments: args,
705
+ ...(meta ? { _meta: meta } : {}),
706
+ },
707
+ };
708
+ const measured = mcpRequest(mcpUrl, accessToken, sessionId, request);
709
+ assert.equal(measured.message.id, id);
710
+ assert.ok(measured.message.result, `tool ${name} did not return a result`);
711
+ if (measured.message.result.isError) {
712
+ throw new Error(`tool ${name} failed: ${JSON.stringify(measured.message.result.content)}`);
713
+ }
714
+ return { result: measured.message.result, http: measured.response };
715
+ }
716
+
717
+ function mcpRequest(mcpUrl, accessToken, sessionId, request) {
718
+ const response = curlRequest({
719
+ method: "POST",
720
+ url: mcpUrl,
721
+ headers: mcpHeaders(accessToken, sessionId),
722
+ body: JSON.stringify(request),
723
+ });
724
+ assert.equal(response.status, 200, response.body);
725
+ return { response, message: parseMcpMessage(response.body, request.id) };
726
+ }
727
+
728
+ function mcpHeaders(accessToken, sessionId) {
729
+ return {
730
+ ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
731
+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
732
+ "content-type": "application/json",
733
+ accept: "application/json, text/event-stream",
734
+ };
735
+ }
736
+
737
+ function parseMcpMessage(body, expectedId) {
738
+ const dataLines = body
739
+ .split(/\r?\n/)
740
+ .filter((line) => line.startsWith("data:"))
741
+ .map((line) => line.slice(5).trim())
742
+ .filter(Boolean);
743
+ const messages = dataLines.length > 0
744
+ ? dataLines.map((line) => JSON.parse(line))
745
+ : [JSON.parse(body)];
746
+ return messages.find((message) => message.id === expectedId) ?? messages[0];
747
+ }
748
+
749
+ function jsonRequest(url, options = {}) {
750
+ const response = curlRequest({
751
+ method: options.method ?? "GET",
752
+ url,
753
+ headers: options.headers,
754
+ body: options.body,
755
+ });
756
+ return { ...response, json: JSON.parse(response.body) };
757
+ }
758
+
759
+ function curlRequest({ method = "GET", url, headers = {}, body }) {
760
+ const marker = `__FORGERELAY_TRAFFIC_${randomUUID()}__`;
761
+ const args = [
762
+ "--silent",
763
+ "--show-error",
764
+ "--max-time",
765
+ "20",
766
+ "--request",
767
+ method,
768
+ "--dump-header",
769
+ "-",
770
+ "--output",
771
+ "-",
772
+ "--write-out",
773
+ `\n${marker}%{http_code}|%{size_upload}|%{size_download}|%{size_request}|%{size_header}`,
774
+ ];
775
+ for (const [name, value] of Object.entries(headers)) args.push("--header", `${name}: ${value}`);
776
+ if (body !== undefined) args.push("--data-binary", "@-");
777
+ args.push(url);
778
+
779
+ const result = spawnSync("curl", args, {
780
+ cwd: repoRoot,
781
+ input: body,
782
+ encoding: "utf8",
783
+ maxBuffer: 40 * 1024 * 1024,
784
+ });
785
+ if (result.status !== 0) {
786
+ throw new Error(`curl ${method} ${url} failed: ${result.stderr.trim() || `exit ${result.status}`}`);
787
+ }
788
+ const statusMarker = `\n${marker}`;
789
+ const markerIndex = result.stdout.lastIndexOf(statusMarker);
790
+ assert.notEqual(markerIndex, -1, `curl response did not contain status marker for ${url}`);
791
+ const rawResponse = result.stdout.slice(0, markerIndex);
792
+ const stats = result.stdout.slice(markerIndex + statusMarker.length).trim().split("|");
793
+ const [status, sizeUpload, sizeDownload, sizeRequest, sizeHeader] = stats.map(Number);
794
+ const separator = rawResponse.indexOf("\r\n\r\n") >= 0 ? "\r\n\r\n" : "\n\n";
795
+ const headerEnd = rawResponse.indexOf(separator);
796
+ assert.notEqual(headerEnd, -1, `curl response did not contain headers for ${url}`);
797
+ const headerBlock = rawResponse.slice(0, headerEnd);
798
+ const responseBody = rawResponse.slice(headerEnd + separator.length);
799
+ const responseHeaders = new Map();
800
+ for (const line of headerBlock.split(/\r?\n/).slice(1)) {
801
+ const colon = line.indexOf(":");
802
+ if (colon < 0) continue;
803
+ responseHeaders.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
804
+ }
805
+ return {
806
+ status,
807
+ headers: responseHeaders,
808
+ body: responseBody,
809
+ sizeUpload,
810
+ sizeDownload,
811
+ sizeRequest,
812
+ sizeHeader,
813
+ };
814
+ }
815
+
816
+ function jsonLogEntries(path) {
817
+ if (!existsSync(path)) return [];
818
+ return readFileSync(path, "utf8")
819
+ .split(/\r?\n/)
820
+ .filter(Boolean)
821
+ .flatMap((line) => {
822
+ try {
823
+ return [JSON.parse(line)];
824
+ } catch {
825
+ return [];
826
+ }
827
+ });
828
+ }
829
+
830
+ function requestLogEntries(path) {
831
+ return jsonLogEntries(path).filter((entry) => entry.event === "http_request");
832
+ }
833
+
834
+ function requestBodiesForRpc(entries, rpcTarget) {
835
+ const requestIds = new Set(entries
836
+ .filter((entry) => entry.event === "mcp_request" && entry.rpcTarget === rpcTarget)
837
+ .map((entry) => entry.requestId)
838
+ .filter(Boolean));
839
+ return entries
840
+ .filter((entry) => entry.event === "http_request" && requestIds.has(entry.requestId))
841
+ .map((entry) => Number(entry.contentLength ?? 0))
842
+ .filter((value) => Number.isFinite(value));
843
+ }
844
+
845
+ function countBy(values, keyFor) {
846
+ const result = {};
847
+ for (const value of values) {
848
+ const key = keyFor(value);
849
+ result[key] = (result[key] ?? 0) + 1;
850
+ }
851
+ return result;
852
+ }
853
+
854
+ async function waitForHealth(child, baseUrl) {
855
+ for (let attempt = 0; attempt < 80; attempt += 1) {
856
+ if (child.exitCode !== null) throw new Error(`debug server exited before health check: ${child.exitCode}`);
857
+ try {
858
+ const response = jsonRequest(`${baseUrl}/healthz`);
859
+ if (response.status === 200) return;
860
+ } catch {
861
+ // still starting
862
+ }
863
+ await delay(100);
864
+ }
865
+ throw new Error(`debug server did not become healthy on ${baseUrl}`);
866
+ }
867
+
868
+ async function stopServer(child) {
869
+ if (child.exitCode !== null) return;
870
+ child.kill("SIGTERM");
871
+ const exited = once(child, "exit");
872
+ await Promise.race([
873
+ exited,
874
+ delay(3000).then(() => {
875
+ if (child.exitCode === null) child.kill("SIGKILL");
876
+ }),
877
+ ]);
878
+ }
879
+
880
+ async function assertPortsFree(ports) {
881
+ const script = [
882
+ "const net=require('node:net');",
883
+ `const ports=${JSON.stringify(ports)};`,
884
+ "let pending=ports.length; let bad=[];",
885
+ "for(const port of ports){const s=net.connect({host:'127.0.0.1',port}); s.setTimeout(250);",
886
+ "s.once('connect',()=>{bad.push(port);s.destroy();done();});",
887
+ "s.once('error',()=>{s.destroy();done();}); s.once('timeout',()=>{s.destroy();done();});}",
888
+ "function done(){if(--pending===0){if(bad.length){console.error('busy:'+bad.join(','));process.exit(2)}}}",
889
+ ].join("");
890
+ const result = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" });
891
+ if (result.status !== 0) throw new Error(`reserved debug port already in use: ${result.stderr.trim()}`);
892
+ }
893
+
894
+ function jsonBytes(value) {
895
+ if (value === undefined) return 0;
896
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
897
+ }
898
+
899
+ function formatBytes(value) {
900
+ const bytes = Number(value);
901
+ if (!Number.isFinite(bytes)) return String(value);
902
+ if (bytes < 1024) return `${bytes.toFixed(0)} B`;
903
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
904
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
905
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
906
+ }
907
+