@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.116
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/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
- package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
- package/dist/agent-cli-CRPPmS3l.js +2 -0
- package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
- package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
- package/dist/background-server-Cw7CcfaZ.js +156 -0
- package/dist/background-server-D2Rrr_e2.js +2 -0
- package/dist/build-info.json +1 -1
- package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
- package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
- package/dist/cli.js +3 -2
- package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
- package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
- package/dist/headless-host-Cp37C-v0.js +2 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
- package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
- package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
- package/dist/profile-BSOkA6A-.js +2 -0
- package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
- package/dist/remote-attach-BAt2eZx3.js +137 -0
- package/dist/remote-extensions-2Cxf3hMo.js +2 -0
- package/dist/remote-extensions-BiNx6NQK.js +323 -0
- package/dist/remote-language-server-DQgWUNeV.js +252 -0
- package/dist/remote-uninstall-DLtDG0o7.js +153 -0
- package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
- package/dist/review-app-launcher-D7I4DGoH.js +222 -0
- package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
- package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
- package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
- package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
- package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
- package/dist/server/desktop-host.js +1663 -266
- package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
- package/dist/sharing/index.d.ts +88 -32
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
- package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
- package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
- package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
- package/instructions/authoring.md +16 -12
- package/instructions/file-lenses.md +4 -2
- package/package.json +8 -13
- package/src/agent-connect.ts +507 -0
- package/src/agent-selection.ts +4 -0
- package/src/ask/agents.ts +59 -13
- package/src/ask/pi-mcp.ts +7 -3
- package/src/ask/thread-state.ts +9 -4
- package/src/ask/thread.ts +88 -50
- package/src/ask/threads.ts +53 -7
- package/src/cli-install.ts +3 -1
- package/src/cli-runner.ts +472 -81
- package/src/cli.ts +1 -1
- package/src/connect-agents.ts +285 -0
- package/src/connect-prompts.ts +60 -6
- package/src/desktop-discovery.ts +24 -0
- package/src/pinned-download.ts +118 -0
- package/src/remote-attach.ts +130 -0
- package/src/remote-extensions.ts +359 -0
- package/src/remote-language-server.ts +428 -0
- package/src/remote-toolchains.ts +107 -0
- package/src/remote-uninstall.ts +280 -0
- package/src/review-api/README.md +1 -3
- package/src/review-api/agent-cli.ts +4 -4
- package/src/review-api/agent-client.ts +23 -0
- package/src/review-api/ask-history.ts +31 -16
- package/src/review-api/authoring-tools.ts +1 -1
- package/src/review-api/blocks/index.ts +2 -4
- package/src/review-api/document.ts +1 -4
- package/src/review-api/http.ts +265 -237
- package/src/review-api/local-data.ts +18 -3
- package/src/review-api/onboarding.ts +478 -0
- package/src/review-api/origin.ts +7 -0
- package/src/review-api/recent-folders.ts +173 -0
- package/src/review-api/store.ts +56 -10
- package/src/review-api/workspaces.ts +56 -11
- package/src/review-app-launcher.ts +61 -3
- package/src/review-stack.ts +162 -36
- package/src/server/background-server.ts +250 -0
- package/src/server/desktop-host-shutdown.ts +24 -0
- package/src/server/desktop-host.ts +39 -24
- package/src/server/desktop-server.ts +68 -118
- package/src/server/global-verb-relay.ts +16 -0
- package/src/server/headless-host.ts +128 -3
- package/src/server/hono-http.ts +6 -1
- package/src/server/legacy-tutorial-cleanup.ts +38 -0
- package/src/server/review-gateway-hosts.ts +379 -0
- package/src/server/review-gateway-list.ts +109 -0
- package/src/server/review-gateway-memory.ts +130 -0
- package/src/server/review-gateway-pushes.ts +142 -0
- package/src/server/review-gateway-streams.ts +415 -0
- package/src/server/review-gateway-transport.ts +204 -0
- package/src/server/review-gateway.ts +433 -0
- package/src/server/review-server-core.ts +36 -6
- package/src/server-discovery.ts +11 -0
- package/src/sharing/import.ts +1 -0
- package/src/ui-telemetry-events.ts +5 -1
- package/src/vsix.ts +122 -0
- package/dist/agent-cli-BR_Jw5BL.js +0 -2
- package/dist/headless-host-BLLJ7Dyu.js +0 -95
- package/dist/profile-aMSw1oCM.js +0 -2
- package/dist/server-discovery-ZVi8Ay8J.js +0 -66
- package/src/fixtures/blocks/tutorial.json +0 -14
- package/src/review-api/blocks/tutorial.ts +0 -23
- package/src/server/tutorial-service.ts +0 -217
- package/tutorial/document.json +0 -216
- package/tutorial/lenses.json +0 -10
- package/tutorial/runtime-manifest.json +0 -11
- package/tutorial/sample-service/package.json +0 -9
- package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
- package/tutorial/sample-service/src/app.ts +0 -26
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
- package/tutorial/sample-service/src/database/schema.ts +0 -9
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
- package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
- package/tutorial/sample-service/src/orders/order-service.ts +0 -35
- package/tutorial/sample-service/src/orders/order.ts +0 -43
- package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
- package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
- package/tutorial/sample-service/tsconfig.json +0 -12
|
@@ -1,32 +1,94 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { S as splitGitPatchFiles, a as diff, o as diffFileSummaries } from "../src-BMPuxWv4.js";
|
|
3
3
|
import { c as jsonString, i as jsonBoolean, n as isJsonObject, p as isObjectValue, s as jsonProperty, u as parseJsonText } from "../src-X9phtB2j.js";
|
|
4
|
-
import { i as SharedReviewStore } from "../client-
|
|
5
|
-
import { c as
|
|
6
|
-
import {
|
|
7
|
-
import { C as resourceReferences
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { t as
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { c as
|
|
17
|
-
import
|
|
4
|
+
import { d as StreamLimitError, f as readBoundedStream, i as SharedReviewStore } from "../client-DhZzPePs.js";
|
|
5
|
+
import { A as reviewDiffrSummarizerInputSchema, E as ReviewGatewayHostSchema, T as ReviewConnectRequestSchema, c as ReviewBugReportMetaV2Schema, d as REVIEW_CLIENT_HEADER, f as REVIEW_CLIENT_REMOTE, g as REVIEW_HOST_HEADER, l as ReviewBugReportRequestSchema, r as parseReviewDesktopVerbFrame, s as parseZod, t as parseReviewCliInstallApplyRequest, u as parseReviewBugReportResponse } from "../src-Bgvf1sEN.js";
|
|
6
|
+
import { i as reviewInstanceDiscoveryPath, o as reviewLegacyDiscoveryPath } from "../review-home-paths-zwevIq4I.js";
|
|
7
|
+
import { C as resourceReferences } from "../stored-document-migration-CFOvjRwW.js";
|
|
8
|
+
import { t as ReviewInputError } from "../input-error-OLgB_h31.js";
|
|
9
|
+
import { I as writePrivateJsonAtomic, M as devReviewHome } from "../s3-config-DDWWMh5x.js";
|
|
10
|
+
import { t as errorMessage } from "../error-message-OtiDonty.js";
|
|
11
|
+
import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-BsuI4WUf.js";
|
|
12
|
+
import { _ as reviewInstanceIdentity, u as REVIEW_INSTANCE_ENV } from "../server-discovery-CifQ3yQS.js";
|
|
13
|
+
import { t as openReviewProfile } from "../profile-pAzg7fxC.js";
|
|
14
|
+
import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-BqB4CiTD.js";
|
|
15
|
+
import { o as REVIEW_TELEMETRY_TABS } from "../request-origin-DaRIV3cB.js";
|
|
16
|
+
import { C as saveDiffrSummarizer, D as findExecutable, E as cliAskTools, S as readDiffrConfig, T as testDiffrSummarizer, _ as DEFAULT_MAX_REQUEST_BYTES, a as drainServerCrashReport, b as GlobalReviewDesktopVerbRelay, c as answerErrorsAsJson, d as coverageModeSchema, f as captureSanitizedUiTelemetry, g as readBoundedRequestJson, h as createNodeRequestListener, l as createWhiteboardCore, m as recordClientError, o as installProcessErrorTelemetry, p as clientErrorsForSession, r as takeOverServerHome, s as serverCrashReportPath, u as serverJson, v as HttpJsonError, w as setDiffrConfigValue, x as migrateDiffrConfig, y as ReviewServerError } from "../headless-host-CVXTSIlx.js";
|
|
17
|
+
import { c as removeLegacyReviewSkills, d as skipCliInstall, i as finishCliInstallUpdate, l as resetCliInstall, m as ALL_INSTALL_TARGETS, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus, v as reviewMcpLaunch, y as cursorInstallDeeplink } from "../account-alias-ChZR3RIU.js";
|
|
18
|
+
import { c as readQuerySchemas } from "../authoring-tools-BXKEwhfi.js";
|
|
19
|
+
import crypto, { createHash, randomBytes } from "node:crypto";
|
|
18
20
|
import { z } from "zod";
|
|
19
|
-
import fs, { existsSync } from "node:fs";
|
|
20
|
-
import { chmod, copyFile,
|
|
21
|
+
import fs, { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { chmod, copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
21
23
|
import path from "node:path";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
22
26
|
import { promisify } from "node:util";
|
|
23
27
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
24
28
|
import { pathToFileURL } from "node:url";
|
|
25
|
-
import { Writable } from "node:stream";
|
|
29
|
+
import { Readable, Writable } from "node:stream";
|
|
26
30
|
import { pipeline } from "node:stream/promises";
|
|
27
31
|
import { gzip, gzipSync } from "node:zlib";
|
|
28
32
|
import { createServer } from "node:http";
|
|
29
33
|
import { Hono } from "hono";
|
|
34
|
+
import { Agent, request } from "undici";
|
|
35
|
+
//#region ../review-protocol/src/review-host-status.ts
|
|
36
|
+
const OFFLINE = {
|
|
37
|
+
status: "offline",
|
|
38
|
+
label: (alias) => `${alias} offline`,
|
|
39
|
+
action: "retry"
|
|
40
|
+
};
|
|
41
|
+
const UNUSABLE = {
|
|
42
|
+
status: "can't be used",
|
|
43
|
+
label: (alias) => `${alias} can't be used`,
|
|
44
|
+
action: "settings"
|
|
45
|
+
};
|
|
46
|
+
const STATUS = {
|
|
47
|
+
online: {
|
|
48
|
+
status: "online",
|
|
49
|
+
label: (alias) => alias
|
|
50
|
+
},
|
|
51
|
+
connecting: {
|
|
52
|
+
status: "connecting",
|
|
53
|
+
label: (alias) => `Connecting to ${alias}…`,
|
|
54
|
+
busy: true
|
|
55
|
+
},
|
|
56
|
+
installing: {
|
|
57
|
+
status: "installing",
|
|
58
|
+
label: (alias) => `Installing on ${alias}…`,
|
|
59
|
+
busy: true
|
|
60
|
+
},
|
|
61
|
+
offline: OFFLINE,
|
|
62
|
+
unreachable: OFFLINE,
|
|
63
|
+
incompatible: {
|
|
64
|
+
status: "needs an update",
|
|
65
|
+
label: (alias) => `${alias} needs an update`,
|
|
66
|
+
action: "install"
|
|
67
|
+
},
|
|
68
|
+
"not-installed": {
|
|
69
|
+
status: "not installed",
|
|
70
|
+
label: (alias) => `Whiteboard not installed on ${alias}`,
|
|
71
|
+
action: "install"
|
|
72
|
+
},
|
|
73
|
+
"auth-failed": {
|
|
74
|
+
status: "can't sign in",
|
|
75
|
+
label: (alias) => `Can't sign in to ${alias}`,
|
|
76
|
+
action: "settings"
|
|
77
|
+
},
|
|
78
|
+
unsupported: UNUSABLE,
|
|
79
|
+
duplicate: UNUSABLE
|
|
80
|
+
};
|
|
81
|
+
/** One wording and one next step per host state, shared by every surface. */
|
|
82
|
+
function reviewHostStatus(host) {
|
|
83
|
+
const { label, ...rest } = STATUS[host.state];
|
|
84
|
+
const words = label(host.alias);
|
|
85
|
+
return {
|
|
86
|
+
...rest,
|
|
87
|
+
label: words,
|
|
88
|
+
sentence: host.detail ?? (words.endsWith("…") ? words : `${words}.`)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
30
92
|
//#region src/review-bundled-tools.ts
|
|
31
93
|
function reviewToolsRoot(env = process.env) {
|
|
32
94
|
return path.join(devReviewHome(env), "review-tools");
|
|
@@ -164,7 +226,11 @@ async function hashFile(filePath) {
|
|
|
164
226
|
}
|
|
165
227
|
//#endregion
|
|
166
228
|
//#region src/server/desktop-host-shutdown.ts
|
|
167
|
-
function
|
|
229
|
+
function postToDesktop(hostProcess, message) {
|
|
230
|
+
if (hostProcess.parentPort?.postMessage) hostProcess.parentPort.postMessage(message);
|
|
231
|
+
else hostProcess.send?.(message);
|
|
232
|
+
}
|
|
233
|
+
function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySetting, onStageRustAnalyzer, onRemoteHosts) {
|
|
168
234
|
const handleMessage = (message) => {
|
|
169
235
|
if (!isJsonObject(message)) return;
|
|
170
236
|
const type = jsonProperty(message, "type");
|
|
@@ -180,6 +246,11 @@ function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySettin
|
|
|
180
246
|
if (type === "stage-rust-analyzer") {
|
|
181
247
|
const path = jsonString(jsonProperty(message, "path"));
|
|
182
248
|
if (path) onStageRustAnalyzer?.(path);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (type === "remote-hosts") {
|
|
252
|
+
const hosts = ReviewGatewayHostSchema.array().safeParse(jsonProperty(message, "hosts"));
|
|
253
|
+
if (hosts.success) onRemoteHosts?.(hosts.data);
|
|
183
254
|
}
|
|
184
255
|
};
|
|
185
256
|
if (hostProcess.parentPort) {
|
|
@@ -193,6 +264,332 @@ function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySettin
|
|
|
193
264
|
return () => hostProcess.off("message", handleMessage);
|
|
194
265
|
}
|
|
195
266
|
//#endregion
|
|
267
|
+
//#region src/agent-connect.ts
|
|
268
|
+
const MARKETPLACE = "devdotfast/whiteboard";
|
|
269
|
+
const PLUGIN = "whiteboard@devfast";
|
|
270
|
+
const STEP_TIMEOUT_MS = 12e4;
|
|
271
|
+
const HARNESSES = {
|
|
272
|
+
claude: {
|
|
273
|
+
name: "Claude Code",
|
|
274
|
+
commands: ["claude"],
|
|
275
|
+
installDirs: [".claude/local", ".local/bin"]
|
|
276
|
+
},
|
|
277
|
+
codex: {
|
|
278
|
+
name: "Codex",
|
|
279
|
+
commands: ["codex"]
|
|
280
|
+
},
|
|
281
|
+
cursor: {
|
|
282
|
+
name: "Cursor",
|
|
283
|
+
commands: ["cursor"],
|
|
284
|
+
homeDir: ".cursor",
|
|
285
|
+
apps: ["/Applications/Cursor.app"]
|
|
286
|
+
},
|
|
287
|
+
opencode: {
|
|
288
|
+
name: "OpenCode",
|
|
289
|
+
commands: ["opencode"],
|
|
290
|
+
installDirs: [".opencode/bin"]
|
|
291
|
+
},
|
|
292
|
+
pi: {
|
|
293
|
+
name: "Pi",
|
|
294
|
+
commands: ["pi"]
|
|
295
|
+
},
|
|
296
|
+
omp: {
|
|
297
|
+
name: "oh-my-pi",
|
|
298
|
+
commands: ["omp"],
|
|
299
|
+
homeDir: ".omp"
|
|
300
|
+
},
|
|
301
|
+
copilot: {
|
|
302
|
+
name: "Copilot CLI",
|
|
303
|
+
commands: ["copilot"]
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const runProcess = (file, args) => new Promise((resolve) => {
|
|
307
|
+
execFile(file, args, {
|
|
308
|
+
timeout: STEP_TIMEOUT_MS,
|
|
309
|
+
maxBuffer: 4194304,
|
|
310
|
+
env: {
|
|
311
|
+
...process.env,
|
|
312
|
+
CI: "1",
|
|
313
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
314
|
+
},
|
|
315
|
+
windowsHide: true
|
|
316
|
+
}, (error, stdout, stderr) => {
|
|
317
|
+
const output = `${stdout}${stderr}`.trim();
|
|
318
|
+
resolve(error ? {
|
|
319
|
+
code: 1,
|
|
320
|
+
output: output || error.message
|
|
321
|
+
} : {
|
|
322
|
+
code: 0,
|
|
323
|
+
output
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
async function exists(target) {
|
|
328
|
+
try {
|
|
329
|
+
await stat(target);
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function findHarness(harness, homeDir, env) {
|
|
336
|
+
const installed = (harness.installDirs ?? []).map((directory) => path.join(homeDir, directory));
|
|
337
|
+
for (const command of harness.commands) {
|
|
338
|
+
const found = await findExecutable(command, env) ?? await findExecutable(command, { PATH: installed.join(path.delimiter) });
|
|
339
|
+
if (found) return found;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function harnessFound(harness, homeDir, env, platform) {
|
|
343
|
+
if (await findHarness(harness, homeDir, env)) return true;
|
|
344
|
+
if (harness.homeDir && await exists(path.join(homeDir, harness.homeDir))) return true;
|
|
345
|
+
if (platform !== "darwin") return false;
|
|
346
|
+
for (const app of harness.apps ?? []) if (await exists(app)) return true;
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
async function readJson(file) {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
352
|
+
} catch {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Follows `keys` down nested JSON objects. */
|
|
357
|
+
function lookup(value, ...keys) {
|
|
358
|
+
let current = value;
|
|
359
|
+
for (const key of keys) {
|
|
360
|
+
if (!isJsonObject(current)) return void 0;
|
|
361
|
+
current = current[key];
|
|
362
|
+
}
|
|
363
|
+
return current;
|
|
364
|
+
}
|
|
365
|
+
/** Whether a `[plugins."…"]` table names the plugin, and does not turn it
|
|
366
|
+
* off. */
|
|
367
|
+
function codexHasPlugin(toml) {
|
|
368
|
+
const lines = toml.split("\n");
|
|
369
|
+
const start = lines.findIndex((line) => line.trim() === `[plugins."${PLUGIN}"]`);
|
|
370
|
+
if (start === -1) return false;
|
|
371
|
+
for (const line of lines.slice(start + 1)) {
|
|
372
|
+
const trimmed = line.trim();
|
|
373
|
+
if (trimmed.startsWith("[")) break;
|
|
374
|
+
if (/^enabled\s*=\s*false\b/.test(trimmed)) return false;
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
/** Whether a harness already has Whiteboard, as its own config records it:
|
|
379
|
+
* the plugin it installed, or the MCP server added to it. Copilot keeps no
|
|
380
|
+
* record Whiteboard reads, so it always installs again, which is harmless. */
|
|
381
|
+
async function harnessConnected(target, homeDir, env, platform) {
|
|
382
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR ?? path.join(homeDir, ".claude");
|
|
383
|
+
const hasServer = async (file, ...keys) => lookup(await readJson(file), ...keys, "whiteboard") !== void 0;
|
|
384
|
+
switch (target) {
|
|
385
|
+
case "claude": {
|
|
386
|
+
if (platform === "win32") return hasServer(path.join(homeDir, ".claude.json"), "mcpServers");
|
|
387
|
+
const [installed, settings] = await Promise.all([readJson(path.join(claudeDir, "plugins", "installed_plugins.json")), readJson(path.join(claudeDir, "settings.json"))]);
|
|
388
|
+
const installs = lookup(installed, "plugins", PLUGIN);
|
|
389
|
+
return Array.isArray(installs) && installs.length > 0 && lookup(settings, "enabledPlugins", PLUGIN) !== false;
|
|
390
|
+
}
|
|
391
|
+
case "codex": try {
|
|
392
|
+
return codexHasPlugin(await readFile(path.join(env.CODEX_HOME ?? path.join(homeDir, ".codex"), "config.toml"), "utf8"));
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
case "opencode": {
|
|
397
|
+
const config = await readJson(path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "opencode", "opencode.json"));
|
|
398
|
+
return lookup(config, "mcp", "whiteboard") !== void 0 || lookup(config, "mcp", "servers", "whiteboard") !== void 0;
|
|
399
|
+
}
|
|
400
|
+
case "pi": return hasServer(path.join(homeDir, ".pi", "agent", "mcp.json"), "mcpServers");
|
|
401
|
+
case "omp": return hasServer(path.join(homeDir, ".omp", "agent", "mcp.json"), "mcpServers");
|
|
402
|
+
case "cursor": return cursorHasServer(homeDir);
|
|
403
|
+
case "copilot": return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Every harness Whiteboard connects to, whether this machine has it, and
|
|
407
|
+
* whether it already has Whiteboard. */
|
|
408
|
+
async function detectConnectAgents(input = {}) {
|
|
409
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
410
|
+
const env = input.env ?? process.env;
|
|
411
|
+
const platform = input.platform ?? process.platform;
|
|
412
|
+
return Promise.all(ALL_INSTALL_TARGETS.map(async (target) => {
|
|
413
|
+
const found = await harnessFound(HARNESSES[target], homeDir, env, platform);
|
|
414
|
+
return {
|
|
415
|
+
target,
|
|
416
|
+
name: HARNESSES[target].name,
|
|
417
|
+
found,
|
|
418
|
+
connected: found && await harnessConnected(target, homeDir, env, platform)
|
|
419
|
+
};
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
const ALREADY = /already/i;
|
|
423
|
+
/** The commands that install Whiteboard into a CLI harness, as
|
|
424
|
+
* connect-prompts.ts tells an agent to run them. */
|
|
425
|
+
function cliSteps(target, launch, platform) {
|
|
426
|
+
const marketplace = {
|
|
427
|
+
args: [
|
|
428
|
+
"plugin",
|
|
429
|
+
"marketplace",
|
|
430
|
+
"add",
|
|
431
|
+
MARKETPLACE
|
|
432
|
+
],
|
|
433
|
+
optional: true
|
|
434
|
+
};
|
|
435
|
+
const windowsServer = (scope) => ({
|
|
436
|
+
args: [
|
|
437
|
+
"mcp",
|
|
438
|
+
"add",
|
|
439
|
+
...scope,
|
|
440
|
+
"whiteboard",
|
|
441
|
+
"--",
|
|
442
|
+
launch.command,
|
|
443
|
+
...launch.args
|
|
444
|
+
],
|
|
445
|
+
done: ALREADY
|
|
446
|
+
});
|
|
447
|
+
switch (target) {
|
|
448
|
+
case "claude": return platform === "win32" ? [windowsServer(["-s", "user"])] : [marketplace, {
|
|
449
|
+
args: [
|
|
450
|
+
"plugin",
|
|
451
|
+
"install",
|
|
452
|
+
PLUGIN,
|
|
453
|
+
"--scope",
|
|
454
|
+
"user"
|
|
455
|
+
],
|
|
456
|
+
done: ALREADY
|
|
457
|
+
}];
|
|
458
|
+
case "codex": return [marketplace, {
|
|
459
|
+
args: [
|
|
460
|
+
"plugin",
|
|
461
|
+
"add",
|
|
462
|
+
PLUGIN
|
|
463
|
+
],
|
|
464
|
+
done: ALREADY
|
|
465
|
+
}];
|
|
466
|
+
case "copilot": return platform === "win32" ? [windowsServer([])] : [marketplace, {
|
|
467
|
+
args: [
|
|
468
|
+
"plugin",
|
|
469
|
+
"install",
|
|
470
|
+
PLUGIN
|
|
471
|
+
],
|
|
472
|
+
done: ALREADY
|
|
473
|
+
}];
|
|
474
|
+
case "opencode": return [{
|
|
475
|
+
args: [
|
|
476
|
+
"mcp",
|
|
477
|
+
"add",
|
|
478
|
+
"--global",
|
|
479
|
+
"whiteboard",
|
|
480
|
+
"--",
|
|
481
|
+
launch.command,
|
|
482
|
+
...launch.args
|
|
483
|
+
],
|
|
484
|
+
done: ALREADY
|
|
485
|
+
}];
|
|
486
|
+
case "pi": return [{
|
|
487
|
+
args: [
|
|
488
|
+
"mcp",
|
|
489
|
+
"add",
|
|
490
|
+
"whiteboard",
|
|
491
|
+
"--",
|
|
492
|
+
launch.command,
|
|
493
|
+
...launch.args
|
|
494
|
+
],
|
|
495
|
+
done: ALREADY
|
|
496
|
+
}];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** oh-my-pi reads its MCP servers from one JSON file; merge ours in. */
|
|
500
|
+
async function connectOmp(homeDir, launch) {
|
|
501
|
+
const file = path.join(homeDir, ".omp", "agent", "mcp.json");
|
|
502
|
+
try {
|
|
503
|
+
const config = await exists(file) ? JSON.parse(await readFile(file, "utf8")) : {};
|
|
504
|
+
if (!isJsonObject(config)) throw new Error(`${file} is not a JSON object.`);
|
|
505
|
+
const servers = isJsonObject(config.mcpServers) ? { ...config.mcpServers } : {};
|
|
506
|
+
servers.whiteboard = {
|
|
507
|
+
command: launch.command,
|
|
508
|
+
args: [...launch.args]
|
|
509
|
+
};
|
|
510
|
+
const next = {
|
|
511
|
+
...config,
|
|
512
|
+
mcpServers: servers
|
|
513
|
+
};
|
|
514
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
515
|
+
await writeFile(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
516
|
+
return {
|
|
517
|
+
target: "omp",
|
|
518
|
+
state: "connected",
|
|
519
|
+
output: `Updated ${file}`
|
|
520
|
+
};
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return {
|
|
523
|
+
target: "omp",
|
|
524
|
+
state: "failed",
|
|
525
|
+
output: error instanceof Error ? error.message : String(error)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Whether Cursor's global MCP config already names Whiteboard's server. */
|
|
530
|
+
async function cursorHasServer(homeDir) {
|
|
531
|
+
try {
|
|
532
|
+
const config = JSON.parse(await readFile(path.join(homeDir, ".cursor", "mcp.json"), "utf8"));
|
|
533
|
+
return isJsonObject(config) && isJsonObject(config.mcpServers) && config.mcpServers.whiteboard !== void 0;
|
|
534
|
+
} catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Cursor only installs an MCP server the user confirms, so open its link,
|
|
539
|
+
* unless it already has one: confirming again would ask for nothing new. */
|
|
540
|
+
async function connectCursor(homeDir, launch, platform, run) {
|
|
541
|
+
if (await cursorHasServer(homeDir)) return {
|
|
542
|
+
target: "cursor",
|
|
543
|
+
state: "connected",
|
|
544
|
+
output: "Cursor already has the whiteboard MCP server."
|
|
545
|
+
};
|
|
546
|
+
const url = cursorInstallDeeplink(launch);
|
|
547
|
+
const [file, args] = platform === "darwin" ? ["open", ["-g", url]] : platform === "win32" ? ["cmd", [
|
|
548
|
+
"/d",
|
|
549
|
+
"/c",
|
|
550
|
+
"start",
|
|
551
|
+
"\"\"",
|
|
552
|
+
url
|
|
553
|
+
]] : ["xdg-open", [url]];
|
|
554
|
+
const result = await run(file, args);
|
|
555
|
+
return {
|
|
556
|
+
target: "cursor",
|
|
557
|
+
state: result.code === 0 ? "approve" : "failed",
|
|
558
|
+
output: result.output
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** Installs Whiteboard's plugin, or its MCP server, into one harness. */
|
|
562
|
+
async function connectAgent(target, input) {
|
|
563
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
564
|
+
const env = input.env ?? process.env;
|
|
565
|
+
const platform = input.platform ?? process.platform;
|
|
566
|
+
const run = input.run ?? runProcess;
|
|
567
|
+
const launch = reviewMcpLaunch(input.hasShim, platform);
|
|
568
|
+
if (target === "omp") return connectOmp(homeDir, launch);
|
|
569
|
+
if (target === "cursor") return connectCursor(homeDir, launch, platform, run);
|
|
570
|
+
const executable = await findHarness(HARNESSES[target], homeDir, env);
|
|
571
|
+
if (!executable) return {
|
|
572
|
+
target,
|
|
573
|
+
state: "failed",
|
|
574
|
+
output: `${HARNESSES[target].name} is not installed.`
|
|
575
|
+
};
|
|
576
|
+
const outputs = [];
|
|
577
|
+
for (const step of cliSteps(target, launch, platform)) {
|
|
578
|
+
const result = await run(executable, step.args);
|
|
579
|
+
outputs.push(result.output);
|
|
580
|
+
if (result.code !== 0 && !step.optional && !step.done?.test(result.output)) return {
|
|
581
|
+
target,
|
|
582
|
+
state: "failed",
|
|
583
|
+
output: outputs.join("\n").trim()
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
target,
|
|
588
|
+
state: "connected",
|
|
589
|
+
output: outputs.join("\n").trim()
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
//#endregion
|
|
196
593
|
//#region src/review-preferences.ts
|
|
197
594
|
const DEFAULT_REVIEW_PREFERENCES = { scratchpadEnabled: false };
|
|
198
595
|
function reviewPreferencesPath(devHome = devReviewHome()) {
|
|
@@ -726,6 +1123,1188 @@ function createJsonReviewReporting(store, telemetry, options = {}) {
|
|
|
726
1123
|
return app;
|
|
727
1124
|
}
|
|
728
1125
|
//#endregion
|
|
1126
|
+
//#region src/server/legacy-tutorial-cleanup.ts
|
|
1127
|
+
/**
|
|
1128
|
+
* Earlier versions shipped a three-minute tutorial: a managed review over a
|
|
1129
|
+
* sample checkout under ~/.dev/tutorial. Whiteboard no longer has one, so
|
|
1130
|
+
* remove what an earlier version left behind. Best effort: a failure leaves
|
|
1131
|
+
* it for the next start.
|
|
1132
|
+
*/
|
|
1133
|
+
async function removeLegacyTutorial(input) {
|
|
1134
|
+
const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
|
|
1135
|
+
try {
|
|
1136
|
+
for (const reviewId of input.store.legacyTutorialIds()) await input.store.execute({ operation: {
|
|
1137
|
+
type: "delete",
|
|
1138
|
+
reviewId
|
|
1139
|
+
} });
|
|
1140
|
+
for (const repository of input.store.repositories()) {
|
|
1141
|
+
const relative = path.relative(tutorialRoot, repository.path);
|
|
1142
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) await input.data.forgetRepository(repository.id);
|
|
1143
|
+
}
|
|
1144
|
+
await rm(tutorialRoot, {
|
|
1145
|
+
recursive: true,
|
|
1146
|
+
force: true,
|
|
1147
|
+
maxRetries: 10
|
|
1148
|
+
});
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
console.warn("[Whiteboard] Removing the old tutorial failed:", error);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const MAX_RETRY_MS = 3e4;
|
|
1154
|
+
const FIRST_BYTE_TIMEOUT_MS = 1e4;
|
|
1155
|
+
const NO_ANSWER = `it did not answer within ${FIRST_BYTE_TIMEOUT_MS / 1e3} seconds`;
|
|
1156
|
+
const jitter = (ms) => ms * (.75 + Math.random() * .5);
|
|
1157
|
+
const STEADY_MS = 1e4;
|
|
1158
|
+
const MAX_LINE_CHARS = 67108864;
|
|
1159
|
+
const sleep = (ms, signal) => setTimeout$1(ms, void 0, { signal }).catch(() => void 0);
|
|
1160
|
+
async function reconnect(signal, attempt) {
|
|
1161
|
+
let wait = 500;
|
|
1162
|
+
while (!signal.aborted) {
|
|
1163
|
+
const started = Date.now();
|
|
1164
|
+
await attempt();
|
|
1165
|
+
if (signal.aborted) return;
|
|
1166
|
+
if (Date.now() - started >= STEADY_MS) wait = 500;
|
|
1167
|
+
await sleep(jitter(wait), signal);
|
|
1168
|
+
wait = Math.min(wait * 2, MAX_RETRY_MS);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
async function readLines(body, line) {
|
|
1172
|
+
const decoder = new TextDecoder();
|
|
1173
|
+
let pending = "";
|
|
1174
|
+
for await (const chunk of body) {
|
|
1175
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
1176
|
+
let end;
|
|
1177
|
+
while ((end = pending.indexOf("\n")) !== -1) {
|
|
1178
|
+
line(pending.slice(0, end));
|
|
1179
|
+
pending = pending.slice(end + 1);
|
|
1180
|
+
}
|
|
1181
|
+
if (pending.length > MAX_LINE_CHARS) throw new Error("A line is too long.");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
function keepOpen(input) {
|
|
1185
|
+
const { remote, signal } = input;
|
|
1186
|
+
reconnect(signal, async () => {
|
|
1187
|
+
let timedOut = false;
|
|
1188
|
+
try {
|
|
1189
|
+
const response = await send(remote, {
|
|
1190
|
+
method: "GET",
|
|
1191
|
+
path: input.path,
|
|
1192
|
+
headers: remoteHeaders(remote),
|
|
1193
|
+
signal
|
|
1194
|
+
});
|
|
1195
|
+
if (response.statusCode === 200) await input.read(response.body);
|
|
1196
|
+
else await response.body.dump();
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
|
|
1199
|
+
}
|
|
1200
|
+
if (signal.aborted) return;
|
|
1201
|
+
if (timedOut) input.hosts.failed(remote, NO_ANSWER);
|
|
1202
|
+
else input.hosts.recheck(remote);
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
const remoteHeaders = (remote) => ({
|
|
1206
|
+
"x-review-token": remote.endpoint?.token ?? "",
|
|
1207
|
+
[REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE
|
|
1208
|
+
});
|
|
1209
|
+
async function send(remote, input, retry = true) {
|
|
1210
|
+
if (!remote.endpoint) throw new Error(`${remote.alias} has no endpoint.`);
|
|
1211
|
+
let response;
|
|
1212
|
+
try {
|
|
1213
|
+
response = await request(new URL(input.path, remote.endpoint.url), {
|
|
1214
|
+
dispatcher: remote.dispatcher,
|
|
1215
|
+
method: input.method,
|
|
1216
|
+
headers: input.headers,
|
|
1217
|
+
body: input.body,
|
|
1218
|
+
signal: input.signal,
|
|
1219
|
+
headersTimeout: input.headersTimeout ?? 1e4,
|
|
1220
|
+
bodyTimeout: 0
|
|
1221
|
+
});
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
if (retry && !input.signal?.aborted && !(input.body instanceof Readable) && errorCode(error) === "UND_ERR_SOCKET" && (input.body === void 0 || reusedSocket(error))) return send(remote, input, false);
|
|
1224
|
+
throw error;
|
|
1225
|
+
}
|
|
1226
|
+
if (response.statusCode === 401) remote.unauthorized?.();
|
|
1227
|
+
return response;
|
|
1228
|
+
}
|
|
1229
|
+
async function readBody(body, limit) {
|
|
1230
|
+
const parts = [];
|
|
1231
|
+
let size = 0;
|
|
1232
|
+
for await (const part of body) {
|
|
1233
|
+
const chunk = part;
|
|
1234
|
+
size += chunk.byteLength;
|
|
1235
|
+
if (size > limit) {
|
|
1236
|
+
body.destroy();
|
|
1237
|
+
throw new StreamLimitError();
|
|
1238
|
+
}
|
|
1239
|
+
parts.push(chunk);
|
|
1240
|
+
}
|
|
1241
|
+
return Buffer.concat(parts, size);
|
|
1242
|
+
}
|
|
1243
|
+
const codedError = z.object({ code: z.string() });
|
|
1244
|
+
const socketError = z.object({ socket: z.object({ bytesRead: z.number() }) });
|
|
1245
|
+
const reusedSocket = (cause) => (socketError.safeParse(cause).data?.socket.bytesRead ?? 0) > 0;
|
|
1246
|
+
const ERROR_WORDS = /* @__PURE__ */ new Map([
|
|
1247
|
+
["ECONNREFUSED", "it refused the connection"],
|
|
1248
|
+
["ECONNRESET", "it closed the connection"],
|
|
1249
|
+
["UND_ERR_SOCKET", "it closed the connection"],
|
|
1250
|
+
["EPIPE", "it closed the connection"],
|
|
1251
|
+
["ETIMEDOUT", "it did not answer"],
|
|
1252
|
+
["UND_ERR_HEADERS_TIMEOUT", NO_ANSWER]
|
|
1253
|
+
]);
|
|
1254
|
+
function errorCode(cause) {
|
|
1255
|
+
if (!(cause instanceof Error)) return String(cause);
|
|
1256
|
+
return codedError.safeParse(cause).data?.code ?? codedError.safeParse(cause.cause).data?.code ?? cause.message;
|
|
1257
|
+
}
|
|
1258
|
+
function errorText(cause) {
|
|
1259
|
+
const code = errorCode(cause);
|
|
1260
|
+
return ERROR_WORDS.get(code) ?? `it failed (${code})`;
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/server/review-gateway-hosts.ts
|
|
1264
|
+
const HEALTH_TIMEOUT_MS = 3e3;
|
|
1265
|
+
const HEARTBEAT_MS = 1e4;
|
|
1266
|
+
const INSTALLS = /* @__PURE__ */ new Set(["incompatible", "not-installed"]);
|
|
1267
|
+
const INSTALL_STEPS = {
|
|
1268
|
+
preparing: "Preparing to install Whiteboard",
|
|
1269
|
+
"waiting-for-lock": "Waiting for another install to finish",
|
|
1270
|
+
package: "Installing the Whiteboard package",
|
|
1271
|
+
done: "Installed; starting the server"
|
|
1272
|
+
};
|
|
1273
|
+
const healthSchema = z.object({
|
|
1274
|
+
ok: z.literal(true),
|
|
1275
|
+
serverId: z.string().optional(),
|
|
1276
|
+
version: z.string()
|
|
1277
|
+
});
|
|
1278
|
+
function createGatewayHosts(input) {
|
|
1279
|
+
const log = input.log ?? (() => {});
|
|
1280
|
+
let hosts = [];
|
|
1281
|
+
let closed = false;
|
|
1282
|
+
const reported = /* @__PURE__ */ new Map();
|
|
1283
|
+
const sharing = (host) => host.serverId === void 0 ? [host] : hosts.filter((other) => other.serverId === host.serverId);
|
|
1284
|
+
const serving = (serverId) => serverId === void 0 ? void 0 : hosts.find((host) => host.status === "online" && host.serverId === serverId && sharing(host).length === 1);
|
|
1285
|
+
function stateOf(host) {
|
|
1286
|
+
const known = {
|
|
1287
|
+
alias: host.alias,
|
|
1288
|
+
...host.serverId !== void 0 && { serverId: host.serverId }
|
|
1289
|
+
};
|
|
1290
|
+
const aliases = sharing(host).map((other) => other.alias);
|
|
1291
|
+
if (aliases.length > 1) return {
|
|
1292
|
+
...known,
|
|
1293
|
+
state: "duplicate",
|
|
1294
|
+
detail: `${aliases.join(", ")} report the same server id, so Whiteboard uses none of them. If they are one machine, keep one alias. If they are separate machines, run \`whiteboard server stop\` and then \`whiteboard server reset-id\` on all but one.`
|
|
1295
|
+
};
|
|
1296
|
+
const detail = host.status === "incompatible" && host.installFailure ? `${host.detail ? `${host.detail} ` : ""}${host.installFailure}` : host.detail;
|
|
1297
|
+
return {
|
|
1298
|
+
...known,
|
|
1299
|
+
state: host.status,
|
|
1300
|
+
...detail !== void 0 && { detail },
|
|
1301
|
+
...INSTALLS.has(host.status) && host.problem?.state !== "incompatible" && { installCommand: [`mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${input.version}/remote/whiteboard-remote-${input.version}-${host.target ?? "linux-x64"}.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote`, "mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard"].join("\n") },
|
|
1302
|
+
...host.declined && host.status !== "online" && host.status !== "connecting" && { declined: true },
|
|
1303
|
+
...host.status === "online" && languageOf(host)
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function report() {
|
|
1307
|
+
const states = hosts.map(stateOf);
|
|
1308
|
+
for (const host of hosts) if (host.serverId !== void 0 && serving(host.serverId) === host) input.machine?.(host.serverId, host.alias);
|
|
1309
|
+
for (const state of states) {
|
|
1310
|
+
const line = `${state.state}${state.detail ? ` (${state.detail})` : ""}`;
|
|
1311
|
+
if (reported.get(state.alias) === line) continue;
|
|
1312
|
+
reported.set(state.alias, line);
|
|
1313
|
+
log(`Host ${state.alias}: ${line}`);
|
|
1314
|
+
}
|
|
1315
|
+
for (const alias of reported.keys()) if (!states.some((state) => state.alias === alias)) reported.delete(alias);
|
|
1316
|
+
input.changed?.();
|
|
1317
|
+
}
|
|
1318
|
+
function restartedHost(host, detail = `${host.alias} restarted; attaching again.`) {
|
|
1319
|
+
if (closed || !host.endpoint) return;
|
|
1320
|
+
host.endpoint = void 0;
|
|
1321
|
+
clearTimeout(host.retry);
|
|
1322
|
+
host.checking?.abort();
|
|
1323
|
+
host.checking = void 0;
|
|
1324
|
+
host.status = "offline";
|
|
1325
|
+
host.detail = detail;
|
|
1326
|
+
report();
|
|
1327
|
+
input.restarted?.(host.alias);
|
|
1328
|
+
}
|
|
1329
|
+
function dispose(host) {
|
|
1330
|
+
clearTimeout(host.retry);
|
|
1331
|
+
host.checking?.abort();
|
|
1332
|
+
host.checking = void 0;
|
|
1333
|
+
host.dispatcher?.destroy();
|
|
1334
|
+
}
|
|
1335
|
+
function create(given) {
|
|
1336
|
+
const host = {
|
|
1337
|
+
alias: given.alias,
|
|
1338
|
+
status: "connecting",
|
|
1339
|
+
retryMs: 500,
|
|
1340
|
+
...languageOf(given),
|
|
1341
|
+
...given.target && { target: given.target }
|
|
1342
|
+
};
|
|
1343
|
+
if (given.endpoint) host.endpoint = given.endpoint;
|
|
1344
|
+
if (given.declined) host.declined = true;
|
|
1345
|
+
if (given.installFailure) host.installFailure = given.installFailure;
|
|
1346
|
+
if (given.problem) {
|
|
1347
|
+
host.problem = given.problem;
|
|
1348
|
+
host.status = given.problem.state;
|
|
1349
|
+
host.detail = given.problem.detail;
|
|
1350
|
+
} else if (given.installing && !given.endpoint) {
|
|
1351
|
+
host.installing = given.installing;
|
|
1352
|
+
host.status = "installing";
|
|
1353
|
+
host.detail = `${INSTALL_STEPS[given.installing.step]}.`;
|
|
1354
|
+
} else if (given.asking && !given.endpoint) {
|
|
1355
|
+
host.asking = given.asking;
|
|
1356
|
+
host.detail = `Waiting for an answer: install Whiteboard ${given.asking} on ${given.alias}?`;
|
|
1357
|
+
} else if (!given.endpoint) host.detail = `Waiting for a connection to ${given.alias}.`;
|
|
1358
|
+
else {
|
|
1359
|
+
host.dispatcher = new Agent();
|
|
1360
|
+
host.unauthorized = () => restartedHost(host);
|
|
1361
|
+
}
|
|
1362
|
+
return host;
|
|
1363
|
+
}
|
|
1364
|
+
function schedule(host, ms) {
|
|
1365
|
+
host.retry = setTimeout(() => void check(host), ms);
|
|
1366
|
+
host.retry.unref();
|
|
1367
|
+
}
|
|
1368
|
+
function retryLater(host) {
|
|
1369
|
+
schedule(host, jitter(host.retryMs));
|
|
1370
|
+
host.retryMs = Math.min(host.retryMs * 2, MAX_RETRY_MS);
|
|
1371
|
+
}
|
|
1372
|
+
async function check(host) {
|
|
1373
|
+
if (closed || !host.endpoint || host.problem) return;
|
|
1374
|
+
clearTimeout(host.retry);
|
|
1375
|
+
host.checking?.abort();
|
|
1376
|
+
const abort = new AbortController();
|
|
1377
|
+
host.checking = abort;
|
|
1378
|
+
const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
|
|
1379
|
+
let health;
|
|
1380
|
+
let reason = "it did not answer";
|
|
1381
|
+
try {
|
|
1382
|
+
const response = await send(host, {
|
|
1383
|
+
method: "GET",
|
|
1384
|
+
path: "/health",
|
|
1385
|
+
headers: { "x-review-token": host.endpoint.token },
|
|
1386
|
+
signal: AbortSignal.any([abort.signal, timeout])
|
|
1387
|
+
});
|
|
1388
|
+
const parsed = healthSchema.safeParse(JSON.parse((await readBody(response.body, 65536)).toString()));
|
|
1389
|
+
if (parsed.success) health = parsed.data;
|
|
1390
|
+
else reason = "it did not answer as a Whiteboard server";
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
if (timeout.aborted) reason = `it did not answer within ${HEALTH_TIMEOUT_MS / 1e3} seconds`;
|
|
1393
|
+
else if (!abort.signal.aborted) reason = errorText(error);
|
|
1394
|
+
}
|
|
1395
|
+
if (host.checking !== abort) return;
|
|
1396
|
+
host.checking = void 0;
|
|
1397
|
+
if (health && health.serverId === void 0) return restartedHost(host);
|
|
1398
|
+
if (!health && host.serverId !== void 0) return restartedHost(host, `${host.alias} is offline: ${reason}; attaching again.`);
|
|
1399
|
+
if (!health) {
|
|
1400
|
+
host.status = "offline";
|
|
1401
|
+
host.detail = `${host.alias} is offline: ${reason}.`;
|
|
1402
|
+
retryLater(host);
|
|
1403
|
+
} else {
|
|
1404
|
+
host.serverId = health.serverId;
|
|
1405
|
+
host.retryMs = 500;
|
|
1406
|
+
if (health.version === "unknown" || health.version !== input.version) {
|
|
1407
|
+
host.status = "incompatible";
|
|
1408
|
+
host.detail = `${host.alias} runs Whiteboard ${health.version}; this Desktop runs ${input.version}. Install Whiteboard ${input.version} on ${host.alias}.`;
|
|
1409
|
+
} else {
|
|
1410
|
+
host.status = "online";
|
|
1411
|
+
host.detail = void 0;
|
|
1412
|
+
}
|
|
1413
|
+
schedule(host, input.heartbeatMs ?? HEARTBEAT_MS);
|
|
1414
|
+
}
|
|
1415
|
+
report();
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
set(list) {
|
|
1419
|
+
const previous = new Map(hosts.map((host) => [host.alias, host]));
|
|
1420
|
+
const next = [];
|
|
1421
|
+
const changed = [];
|
|
1422
|
+
for (const given of list) {
|
|
1423
|
+
if (next.some((host) => host.alias === given.alias)) continue;
|
|
1424
|
+
const current = previous.get(given.alias);
|
|
1425
|
+
if (current && JSON.stringify([
|
|
1426
|
+
current.endpoint,
|
|
1427
|
+
current.problem,
|
|
1428
|
+
current.installing,
|
|
1429
|
+
current.declined,
|
|
1430
|
+
current.asking,
|
|
1431
|
+
current.installFailure
|
|
1432
|
+
]) === JSON.stringify([
|
|
1433
|
+
given.endpoint,
|
|
1434
|
+
given.problem,
|
|
1435
|
+
given.installing,
|
|
1436
|
+
given.declined,
|
|
1437
|
+
given.asking,
|
|
1438
|
+
given.installFailure
|
|
1439
|
+
])) {
|
|
1440
|
+
previous.delete(given.alias);
|
|
1441
|
+
current.languageFeatures = given.languageFeatures;
|
|
1442
|
+
current.languageFeaturesDetail = given.languageFeaturesDetail;
|
|
1443
|
+
current.languageGroups = given.languageGroups;
|
|
1444
|
+
current.target = given.target;
|
|
1445
|
+
next.push(current);
|
|
1446
|
+
} else {
|
|
1447
|
+
const host = create(given);
|
|
1448
|
+
next.push(host);
|
|
1449
|
+
changed.push(host);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
for (const gone of previous.values()) dispose(gone);
|
|
1453
|
+
hosts = next;
|
|
1454
|
+
for (const host of changed) check(host);
|
|
1455
|
+
report();
|
|
1456
|
+
},
|
|
1457
|
+
states: () => hosts.map(stateOf),
|
|
1458
|
+
serving: (serverId) => serving(serverId),
|
|
1459
|
+
online: () => hosts.filter((host) => serving(host.serverId) === host),
|
|
1460
|
+
failed(remote, reason) {
|
|
1461
|
+
const host = hosts.find((candidate) => candidate === remote);
|
|
1462
|
+
if (!host || host.status !== "online") return;
|
|
1463
|
+
host.status = "offline";
|
|
1464
|
+
host.detail = `${host.alias} is offline: ${reason}.`;
|
|
1465
|
+
report();
|
|
1466
|
+
check(host);
|
|
1467
|
+
},
|
|
1468
|
+
recheck(remote) {
|
|
1469
|
+
const host = hosts.find((candidate) => candidate === remote);
|
|
1470
|
+
if (host && !host.checking) check(host);
|
|
1471
|
+
},
|
|
1472
|
+
close() {
|
|
1473
|
+
closed = true;
|
|
1474
|
+
for (const host of hosts) dispose(host);
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const languageOf = ({ languageFeatures, languageFeaturesDetail, languageGroups }) => ({
|
|
1479
|
+
...languageFeatures !== void 0 && { languageFeatures },
|
|
1480
|
+
...languageFeaturesDetail !== void 0 && { languageFeaturesDetail },
|
|
1481
|
+
...languageGroups !== void 0 && { languageGroups }
|
|
1482
|
+
});
|
|
1483
|
+
//#endregion
|
|
1484
|
+
//#region src/server/review-gateway-memory.ts
|
|
1485
|
+
const LIST_MODES = ["structural", "textual"];
|
|
1486
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1487
|
+
const listEntrySchema = z.looseObject({
|
|
1488
|
+
reviewId: z.string().regex(UUID),
|
|
1489
|
+
version: z.number(),
|
|
1490
|
+
title: z.string(),
|
|
1491
|
+
createdAt: z.string(),
|
|
1492
|
+
repositoryName: z.string(),
|
|
1493
|
+
viewedAt: z.string().nullable(),
|
|
1494
|
+
trashedAt: z.string().nullable(),
|
|
1495
|
+
repositoryGroup: z.object({
|
|
1496
|
+
key: z.string(),
|
|
1497
|
+
label: z.string()
|
|
1498
|
+
}).optional()
|
|
1499
|
+
});
|
|
1500
|
+
const listEntriesSchema = z.array(z.unknown()).transform((entries) => entries.filter((entry) => listEntrySchema.safeParse(entry).success));
|
|
1501
|
+
const lastListSchema = z.object({
|
|
1502
|
+
structural: listEntriesSchema.optional(),
|
|
1503
|
+
textual: listEntriesSchema.optional()
|
|
1504
|
+
});
|
|
1505
|
+
const memorySchema = z.record(z.string(), z.object({
|
|
1506
|
+
alias: z.string(),
|
|
1507
|
+
lastList: lastListSchema.optional()
|
|
1508
|
+
}));
|
|
1509
|
+
function gatewayMemoryPath(home) {
|
|
1510
|
+
return path.join(home, "remotes.json");
|
|
1511
|
+
}
|
|
1512
|
+
function openGatewayMemory(home, log = () => {}) {
|
|
1513
|
+
const file = gatewayMemoryPath(home);
|
|
1514
|
+
const servers = /* @__PURE__ */ new Map();
|
|
1515
|
+
try {
|
|
1516
|
+
const saved = memorySchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
1517
|
+
for (const [serverId, { alias, lastList }] of Object.entries(saved)) servers.set(serverId, {
|
|
1518
|
+
alias,
|
|
1519
|
+
lastList: lastList ?? {}
|
|
1520
|
+
});
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
if (error.code !== "ENOENT") log(`Ignoring unreadable remote review memory ${file}: ${String(error)}`);
|
|
1523
|
+
}
|
|
1524
|
+
let writing = Promise.resolve();
|
|
1525
|
+
let queued = false;
|
|
1526
|
+
const save = () => {
|
|
1527
|
+
if (queued) return;
|
|
1528
|
+
queued = true;
|
|
1529
|
+
writing = writing.then(() => {
|
|
1530
|
+
queued = false;
|
|
1531
|
+
return writePrivateJsonAtomic(file, Object.fromEntries(servers));
|
|
1532
|
+
}).catch((cause) => log(`Could not save remote review memory: ${errorMessage(cause)}`));
|
|
1533
|
+
};
|
|
1534
|
+
return {
|
|
1535
|
+
serverIdOf(alias) {
|
|
1536
|
+
for (const [serverId, server] of servers) if (server.alias === alias) return serverId;
|
|
1537
|
+
},
|
|
1538
|
+
list: (serverId, mode) => servers.get(serverId)?.lastList[mode],
|
|
1539
|
+
setList(serverId, alias, mode, reviews) {
|
|
1540
|
+
let server = servers.get(serverId);
|
|
1541
|
+
if (!server) {
|
|
1542
|
+
server = {
|
|
1543
|
+
alias,
|
|
1544
|
+
lastList: {}
|
|
1545
|
+
};
|
|
1546
|
+
servers.set(serverId, server);
|
|
1547
|
+
} else if (server.alias === alias && JSON.stringify(server.lastList[mode]) === JSON.stringify(reviews)) return;
|
|
1548
|
+
server.alias = alias;
|
|
1549
|
+
server.lastList[mode] = reviews;
|
|
1550
|
+
save();
|
|
1551
|
+
},
|
|
1552
|
+
rename(serverId, alias) {
|
|
1553
|
+
const server = servers.get(serverId);
|
|
1554
|
+
if (!server || server.alias === alias) return;
|
|
1555
|
+
server.alias = alias;
|
|
1556
|
+
save();
|
|
1557
|
+
},
|
|
1558
|
+
flush: () => writing
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
//#endregion
|
|
1562
|
+
//#region src/server/review-gateway-list.ts
|
|
1563
|
+
function decorate(entry, state, hostState) {
|
|
1564
|
+
const { alias } = state;
|
|
1565
|
+
const online = hostState === "online";
|
|
1566
|
+
const features = online && state.languageFeatures === true;
|
|
1567
|
+
const reason = online ? features ? void 0 : state.languageFeaturesDetail : reviewHostStatus({
|
|
1568
|
+
alias,
|
|
1569
|
+
state: hostState,
|
|
1570
|
+
detail: hostState === state.state ? state.detail : void 0
|
|
1571
|
+
}).sentence;
|
|
1572
|
+
return {
|
|
1573
|
+
...entry,
|
|
1574
|
+
...entry.repositoryGroup && { repositoryGroup: {
|
|
1575
|
+
key: `${alias}:${entry.repositoryGroup.key}`,
|
|
1576
|
+
label: entry.repositoryGroup.label
|
|
1577
|
+
} },
|
|
1578
|
+
host: alias,
|
|
1579
|
+
hostState,
|
|
1580
|
+
available: {
|
|
1581
|
+
sourceWindows: features,
|
|
1582
|
+
...reason && { reason }
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
const hostStateOf = (state, serving) => {
|
|
1587
|
+
if (serving) return "online";
|
|
1588
|
+
return state.state === "online" || state.state === "duplicate" ? "offline" : state.state;
|
|
1589
|
+
};
|
|
1590
|
+
function* servers(source) {
|
|
1591
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1592
|
+
for (const state of source.states) {
|
|
1593
|
+
const serverId = state.serverId ?? source.serverIdOf(state.alias);
|
|
1594
|
+
if (serverId === void 0 || seen.has(serverId)) continue;
|
|
1595
|
+
seen.add(serverId);
|
|
1596
|
+
yield {
|
|
1597
|
+
state,
|
|
1598
|
+
serverId
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function listedBy(reviewId, source) {
|
|
1603
|
+
for (const server of servers(source)) if (LIST_MODES.some((mode) => source.list(server.serverId, mode)?.some((entry) => entry.reviewId === reviewId))) return server;
|
|
1604
|
+
}
|
|
1605
|
+
function mergeLists(mode, local, source) {
|
|
1606
|
+
const ids = new Set(local.map((entry) => entry.reviewId));
|
|
1607
|
+
const merged = [...local];
|
|
1608
|
+
for (const { state, serverId } of servers(source)) {
|
|
1609
|
+
const hostState = hostStateOf(state, source.serving(serverId));
|
|
1610
|
+
for (const entry of source.list(serverId, mode) ?? []) if (!ids.has(entry.reviewId)) {
|
|
1611
|
+
ids.add(entry.reviewId);
|
|
1612
|
+
merged.push(decorate(entry, state, hostState));
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return merged;
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/server/review-gateway-pushes.ts
|
|
1619
|
+
const REMOTE_VERBS = /* @__PURE__ */ new Set([
|
|
1620
|
+
"authoringCapabilities",
|
|
1621
|
+
"openApiReview",
|
|
1622
|
+
"focusWindow"
|
|
1623
|
+
]);
|
|
1624
|
+
function createGatewayPushes(input) {
|
|
1625
|
+
const { hosts, relay } = input;
|
|
1626
|
+
const links = /* @__PURE__ */ new Map();
|
|
1627
|
+
async function answer(remote, request) {
|
|
1628
|
+
if (!REMOTE_VERBS.has(request.name)) return {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: `${request.name} is not available from another machine.`
|
|
1631
|
+
};
|
|
1632
|
+
if (request.name === "openApiReview") {
|
|
1633
|
+
const { reviewId } = request.args;
|
|
1634
|
+
if (!UUID.test(reviewId)) return {
|
|
1635
|
+
ok: false,
|
|
1636
|
+
error: `${reviewId} cannot be opened from ${remote.alias}: a review on another machine has a UUID.`
|
|
1637
|
+
};
|
|
1638
|
+
const refused = await input.claim(remote, reviewId);
|
|
1639
|
+
if (refused) return {
|
|
1640
|
+
ok: false,
|
|
1641
|
+
error: refused
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
return relay.dispatch(request);
|
|
1645
|
+
}
|
|
1646
|
+
async function reply(remote, id, response) {
|
|
1647
|
+
try {
|
|
1648
|
+
await (await send(remote, {
|
|
1649
|
+
method: "POST",
|
|
1650
|
+
path: "/control/result",
|
|
1651
|
+
headers: {
|
|
1652
|
+
...remoteHeaders(remote),
|
|
1653
|
+
"content-type": "application/json"
|
|
1654
|
+
},
|
|
1655
|
+
body: Buffer.from(JSON.stringify({
|
|
1656
|
+
id,
|
|
1657
|
+
response
|
|
1658
|
+
})),
|
|
1659
|
+
signal: AbortSignal.timeout(FIRST_BYTE_TIMEOUT_MS)
|
|
1660
|
+
})).body.dump();
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
input.log(`Could not answer ${remote.alias}'s push: ${errorText(error)}.`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
async function push(remote, data) {
|
|
1666
|
+
let frame;
|
|
1667
|
+
try {
|
|
1668
|
+
frame = parseReviewDesktopVerbFrame(parseJsonText(data));
|
|
1669
|
+
} catch {
|
|
1670
|
+
input.log(`Ignored an unreadable push from ${remote.alias}.`);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
let response;
|
|
1674
|
+
try {
|
|
1675
|
+
response = await answer(remote, frame.request);
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
response = {
|
|
1678
|
+
ok: false,
|
|
1679
|
+
error: errorText(error)
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
await reply(remote, frame.id, response);
|
|
1683
|
+
}
|
|
1684
|
+
return {
|
|
1685
|
+
changed() {
|
|
1686
|
+
const online = new Set(relay.attached ? hosts.online() : []);
|
|
1687
|
+
for (const [remote, abort] of links) if (!online.has(remote)) {
|
|
1688
|
+
abort.abort();
|
|
1689
|
+
links.delete(remote);
|
|
1690
|
+
}
|
|
1691
|
+
for (const remote of online) {
|
|
1692
|
+
if (links.has(remote)) continue;
|
|
1693
|
+
const abort = new AbortController();
|
|
1694
|
+
links.set(remote, abort);
|
|
1695
|
+
keepOpen({
|
|
1696
|
+
hosts,
|
|
1697
|
+
remote,
|
|
1698
|
+
path: "/control",
|
|
1699
|
+
signal: abort.signal,
|
|
1700
|
+
read: (body) => readLines(body, (line) => {
|
|
1701
|
+
if (line.startsWith("data: ")) push(remote, line.slice(6));
|
|
1702
|
+
})
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
close() {
|
|
1707
|
+
for (const abort of links.values()) abort.abort();
|
|
1708
|
+
links.clear();
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/server/review-gateway-streams.ts
|
|
1714
|
+
const subscriptionsSchema = z.array(z.strictObject({
|
|
1715
|
+
reviewId: z.string().min(1).nullable(),
|
|
1716
|
+
mode: coverageModeSchema
|
|
1717
|
+
}));
|
|
1718
|
+
const lineSchema = z.discriminatedUnion("kind", [z.object({
|
|
1719
|
+
kind: z.literal("list"),
|
|
1720
|
+
mode: z.enum(LIST_MODES),
|
|
1721
|
+
reviews: z.array(z.unknown())
|
|
1722
|
+
}), z.object({
|
|
1723
|
+
kind: z.literal("review"),
|
|
1724
|
+
reviewId: z.string(),
|
|
1725
|
+
value: z.unknown().optional()
|
|
1726
|
+
})]);
|
|
1727
|
+
const downDetail = (down) => reviewHostStatus(down).sentence;
|
|
1728
|
+
const watchPath = (subscriptions) => `/reviews-api/watch?subscriptions=${encodeURIComponent(JSON.stringify(subscriptions))}`;
|
|
1729
|
+
function parseLine(text) {
|
|
1730
|
+
try {
|
|
1731
|
+
return lineSchema.safeParse(parseJsonText(text)).data;
|
|
1732
|
+
} catch {
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
function createGatewayStreams(input) {
|
|
1737
|
+
const { hosts, memory } = input;
|
|
1738
|
+
const feeds = /* @__PURE__ */ new Map();
|
|
1739
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1740
|
+
const passThrough = /* @__PURE__ */ new Set();
|
|
1741
|
+
const merge = (mode, local) => mergeLists(mode, local, input.lists());
|
|
1742
|
+
function feedLine(remote, text) {
|
|
1743
|
+
const line = parseLine(text);
|
|
1744
|
+
if (line?.kind !== "list" || remote.serverId === void 0) return;
|
|
1745
|
+
memory.setList(remote.serverId, remote.alias, line.mode, listEntriesSchema.parse(line.reviews));
|
|
1746
|
+
input.listed();
|
|
1747
|
+
for (const client of clients) client.refresh();
|
|
1748
|
+
}
|
|
1749
|
+
function syncFeeds() {
|
|
1750
|
+
const online = new Set(hosts.online());
|
|
1751
|
+
for (const [remote, abort] of feeds) if (!online.has(remote)) {
|
|
1752
|
+
abort.abort();
|
|
1753
|
+
feeds.delete(remote);
|
|
1754
|
+
}
|
|
1755
|
+
for (const remote of online) {
|
|
1756
|
+
if (feeds.has(remote)) continue;
|
|
1757
|
+
const abort = new AbortController();
|
|
1758
|
+
feeds.set(remote, abort);
|
|
1759
|
+
keepOpen({
|
|
1760
|
+
hosts,
|
|
1761
|
+
remote,
|
|
1762
|
+
path: watchPath(LIST_MODES.map((mode) => ({
|
|
1763
|
+
reviewId: null,
|
|
1764
|
+
mode
|
|
1765
|
+
}))),
|
|
1766
|
+
signal: abort.signal,
|
|
1767
|
+
read: (body) => readLines(body, (text) => feedLine(remote, text))
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
function openClient(subscriptions, signal) {
|
|
1772
|
+
const modes = /* @__PURE__ */ new Set();
|
|
1773
|
+
const reviews = /* @__PURE__ */ new Map();
|
|
1774
|
+
for (const { reviewId, mode } of subscriptions) if (reviewId === null) modes.add(mode);
|
|
1775
|
+
else if (!reviews.has(reviewId)) reviews.set(reviewId, mode);
|
|
1776
|
+
const encoder = new TextEncoder();
|
|
1777
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1778
|
+
const sent = /* @__PURE__ */ new Map();
|
|
1779
|
+
const localLists = /* @__PURE__ */ new Map();
|
|
1780
|
+
const reportedDown = /* @__PURE__ */ new Set();
|
|
1781
|
+
const upstreams = /* @__PURE__ */ new Map();
|
|
1782
|
+
let controller;
|
|
1783
|
+
let flushing = false;
|
|
1784
|
+
let stopped = false;
|
|
1785
|
+
const flush = () => {
|
|
1786
|
+
if (!controller || !pending.size) return;
|
|
1787
|
+
if (controller.desiredSize === null || controller.desiredSize <= 0) return;
|
|
1788
|
+
const lines = [];
|
|
1789
|
+
for (const [item, line] of pending) if (sent.get(item) !== line) {
|
|
1790
|
+
lines.push(line);
|
|
1791
|
+
sent.set(item, line);
|
|
1792
|
+
}
|
|
1793
|
+
pending.clear();
|
|
1794
|
+
if (lines.length) controller.enqueue(encoder.encode(`${lines.join("\n")}\n`));
|
|
1795
|
+
};
|
|
1796
|
+
const emit = (item, line) => {
|
|
1797
|
+
if (stopped) return;
|
|
1798
|
+
pending.set(item, line);
|
|
1799
|
+
if (flushing) return;
|
|
1800
|
+
flushing = true;
|
|
1801
|
+
queueMicrotask(() => {
|
|
1802
|
+
flushing = false;
|
|
1803
|
+
flush();
|
|
1804
|
+
});
|
|
1805
|
+
};
|
|
1806
|
+
const emitList = (mode) => {
|
|
1807
|
+
const local = localLists.get(mode);
|
|
1808
|
+
if (local) emit(`list:${mode}`, JSON.stringify({
|
|
1809
|
+
kind: "list",
|
|
1810
|
+
mode,
|
|
1811
|
+
reviews: merge(mode, local)
|
|
1812
|
+
}));
|
|
1813
|
+
};
|
|
1814
|
+
const emitReview = (reviewId, text) => {
|
|
1815
|
+
reportedDown.delete(reviewId);
|
|
1816
|
+
emit(`review:${reviewId}`, text);
|
|
1817
|
+
};
|
|
1818
|
+
const down = (reviewId, state) => {
|
|
1819
|
+
if (reportedDown.has(reviewId)) return;
|
|
1820
|
+
reportedDown.add(reviewId);
|
|
1821
|
+
emit(`review:${reviewId}`, JSON.stringify({
|
|
1822
|
+
kind: "review",
|
|
1823
|
+
reviewId,
|
|
1824
|
+
error: downDetail(state)
|
|
1825
|
+
}));
|
|
1826
|
+
};
|
|
1827
|
+
const openLocal = (ids, abort) => {
|
|
1828
|
+
const path = watchPath([...[...modes].map((mode) => ({
|
|
1829
|
+
reviewId: null,
|
|
1830
|
+
mode
|
|
1831
|
+
})), ...[...ids].map(([reviewId, mode]) => ({
|
|
1832
|
+
reviewId,
|
|
1833
|
+
mode
|
|
1834
|
+
}))]);
|
|
1835
|
+
reconnect(abort, async () => {
|
|
1836
|
+
try {
|
|
1837
|
+
const response = await input.local(new Request(`http://gateway${path}`));
|
|
1838
|
+
if (response.body) await readLines(Readable.fromWeb(response.body, { signal: abort }), (text) => {
|
|
1839
|
+
const line = parseLine(text);
|
|
1840
|
+
if (line?.kind === "list" && modes.has(line.mode)) {
|
|
1841
|
+
localLists.set(line.mode, line.reviews);
|
|
1842
|
+
emitList(line.mode);
|
|
1843
|
+
} else if (line?.kind === "review" && ids.has(line.reviewId)) emitReview(line.reviewId, text);
|
|
1844
|
+
});
|
|
1845
|
+
} catch {}
|
|
1846
|
+
});
|
|
1847
|
+
};
|
|
1848
|
+
const openRemote = (remote, ids, abort) => keepOpen({
|
|
1849
|
+
hosts,
|
|
1850
|
+
remote,
|
|
1851
|
+
path: watchPath([...ids].map(([reviewId, mode]) => ({
|
|
1852
|
+
reviewId,
|
|
1853
|
+
mode
|
|
1854
|
+
}))),
|
|
1855
|
+
signal: abort,
|
|
1856
|
+
read: (body) => readLines(body, (text) => {
|
|
1857
|
+
const line = parseLine(text);
|
|
1858
|
+
if (line?.kind === "review" && ids.has(line.reviewId)) emitReview(line.reviewId, text);
|
|
1859
|
+
})
|
|
1860
|
+
});
|
|
1861
|
+
const client = {
|
|
1862
|
+
refresh() {
|
|
1863
|
+
if (stopped) return;
|
|
1864
|
+
for (const mode of modes) emitList(mode);
|
|
1865
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
1866
|
+
const want = (key, reviewId) => {
|
|
1867
|
+
let ids = wanted.get(key);
|
|
1868
|
+
if (!ids) wanted.set(key, ids = /* @__PURE__ */ new Map());
|
|
1869
|
+
ids.set(reviewId, reviews.get(reviewId));
|
|
1870
|
+
};
|
|
1871
|
+
if (modes.size) wanted.set("local", /* @__PURE__ */ new Map());
|
|
1872
|
+
for (const reviewId of reviews.keys()) {
|
|
1873
|
+
const located = input.locate(reviewId);
|
|
1874
|
+
if (located === void 0 || located === "local") want("local", reviewId);
|
|
1875
|
+
else if ("remote" in located) want(located.remote, reviewId);
|
|
1876
|
+
else if (located.down.state !== "connecting") down(reviewId, located.down);
|
|
1877
|
+
}
|
|
1878
|
+
for (const [key, upstream] of upstreams) {
|
|
1879
|
+
const ids = wanted.get(key);
|
|
1880
|
+
if (ids && JSON.stringify([...ids]) === upstream.key) continue;
|
|
1881
|
+
upstream.abort.abort();
|
|
1882
|
+
upstreams.delete(key);
|
|
1883
|
+
}
|
|
1884
|
+
for (const [key, ids] of wanted) {
|
|
1885
|
+
if (upstreams.has(key)) continue;
|
|
1886
|
+
const abort = new AbortController();
|
|
1887
|
+
upstreams.set(key, {
|
|
1888
|
+
key: JSON.stringify([...ids]),
|
|
1889
|
+
abort
|
|
1890
|
+
});
|
|
1891
|
+
if (key === "local") openLocal(ids, abort.signal);
|
|
1892
|
+
else openRemote(key, ids, abort.signal);
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
stop() {
|
|
1896
|
+
if (stopped) return;
|
|
1897
|
+
stopped = true;
|
|
1898
|
+
clients.delete(client);
|
|
1899
|
+
for (const upstream of upstreams.values()) upstream.abort.abort();
|
|
1900
|
+
upstreams.clear();
|
|
1901
|
+
try {
|
|
1902
|
+
controller?.close();
|
|
1903
|
+
} catch {}
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
signal.addEventListener("abort", () => client.stop(), { once: true });
|
|
1907
|
+
const body = new ReadableStream({
|
|
1908
|
+
start(started) {
|
|
1909
|
+
controller = started;
|
|
1910
|
+
clients.add(client);
|
|
1911
|
+
client.refresh();
|
|
1912
|
+
},
|
|
1913
|
+
pull: flush,
|
|
1914
|
+
cancel: () => client.stop()
|
|
1915
|
+
});
|
|
1916
|
+
return new Response(body, { headers: {
|
|
1917
|
+
"content-type": "application/x-ndjson",
|
|
1918
|
+
"cache-control": "no-store"
|
|
1919
|
+
} });
|
|
1920
|
+
}
|
|
1921
|
+
async function passLocal(request) {
|
|
1922
|
+
const response = await input.local(request);
|
|
1923
|
+
if (!response.body) return response;
|
|
1924
|
+
let stop = () => {};
|
|
1925
|
+
const { readable, writable } = new TransformStream({ start(controller) {
|
|
1926
|
+
stop = () => controller.terminate();
|
|
1927
|
+
} });
|
|
1928
|
+
passThrough.add(stop);
|
|
1929
|
+
response.body.pipeTo(writable).catch(() => void 0).finally(() => passThrough.delete(stop));
|
|
1930
|
+
return new Response(readable, response);
|
|
1931
|
+
}
|
|
1932
|
+
return {
|
|
1933
|
+
watch(request) {
|
|
1934
|
+
if (!hosts.states().length) return passLocal(request);
|
|
1935
|
+
let subscriptions;
|
|
1936
|
+
try {
|
|
1937
|
+
subscriptions = subscriptionsSchema.parse(JSON.parse(new URL(request.url).searchParams.get("subscriptions") ?? ""));
|
|
1938
|
+
} catch {
|
|
1939
|
+
return input.local(request);
|
|
1940
|
+
}
|
|
1941
|
+
return openClient(subscriptions, request.signal);
|
|
1942
|
+
},
|
|
1943
|
+
async list(request) {
|
|
1944
|
+
const response = await input.local(request);
|
|
1945
|
+
if (!response.ok || !hosts.states().length) return response;
|
|
1946
|
+
const mode = coverageModeSchema.safeParse(new URL(request.url).searchParams.get("mode") ?? void 0).data ?? "structural";
|
|
1947
|
+
const local = await response.json();
|
|
1948
|
+
return serverJson(200, merge(mode, local));
|
|
1949
|
+
},
|
|
1950
|
+
changed() {
|
|
1951
|
+
syncFeeds();
|
|
1952
|
+
if (hosts.states().length) {
|
|
1953
|
+
for (const stop of passThrough) stop();
|
|
1954
|
+
passThrough.clear();
|
|
1955
|
+
}
|
|
1956
|
+
for (const client of clients) client.refresh();
|
|
1957
|
+
},
|
|
1958
|
+
close() {
|
|
1959
|
+
for (const abort of feeds.values()) abort.abort();
|
|
1960
|
+
feeds.clear();
|
|
1961
|
+
for (const stop of passThrough) stop();
|
|
1962
|
+
for (const client of clients) client.stop();
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/server/review-gateway.ts
|
|
1968
|
+
const LOCAL_ROUTES = /* @__PURE__ */ new Set([
|
|
1969
|
+
"repositories",
|
|
1970
|
+
"resources",
|
|
1971
|
+
"workspace-cleanup",
|
|
1972
|
+
"authoring",
|
|
1973
|
+
"capabilities",
|
|
1974
|
+
"status",
|
|
1975
|
+
"instructions",
|
|
1976
|
+
"sharing",
|
|
1977
|
+
"commands"
|
|
1978
|
+
]);
|
|
1979
|
+
const REMOTE_ROUTES = [
|
|
1980
|
+
{
|
|
1981
|
+
method: "GET",
|
|
1982
|
+
path: ""
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
method: "GET",
|
|
1986
|
+
path: "progress"
|
|
1987
|
+
},
|
|
1988
|
+
{
|
|
1989
|
+
method: "POST",
|
|
1990
|
+
path: "progress"
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
method: "GET",
|
|
1994
|
+
path: "commits"
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
method: "GET",
|
|
1998
|
+
path: "diff"
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
method: "GET",
|
|
2002
|
+
path: "structural-diff"
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
method: "GET",
|
|
2006
|
+
path: "stack"
|
|
2007
|
+
},
|
|
2008
|
+
{
|
|
2009
|
+
method: "GET",
|
|
2010
|
+
path: "tree"
|
|
2011
|
+
},
|
|
2012
|
+
{
|
|
2013
|
+
method: "GET",
|
|
2014
|
+
path: "file"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
method: "GET",
|
|
2018
|
+
path: "resources/:name"
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
method: "GET",
|
|
2022
|
+
path: "maps/:name"
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
method: "POST",
|
|
2026
|
+
path: "navigator"
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
method: "POST",
|
|
2030
|
+
path: "copy-context"
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
method: "GET",
|
|
2034
|
+
path: "language-context",
|
|
2035
|
+
slow: true
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
method: "GET",
|
|
2039
|
+
path: "ask/agents"
|
|
2040
|
+
},
|
|
2041
|
+
{
|
|
2042
|
+
method: "GET",
|
|
2043
|
+
path: "ask/agents/:agent/offer",
|
|
2044
|
+
slow: true
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
method: "GET",
|
|
2048
|
+
path: "ask/mentions",
|
|
2049
|
+
slow: true
|
|
2050
|
+
},
|
|
2051
|
+
{
|
|
2052
|
+
method: "GET",
|
|
2053
|
+
path: "ask/threads"
|
|
2054
|
+
},
|
|
2055
|
+
{
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
path: "ask/watch"
|
|
2058
|
+
},
|
|
2059
|
+
{
|
|
2060
|
+
method: "GET",
|
|
2061
|
+
path: "ask/:thread"
|
|
2062
|
+
},
|
|
2063
|
+
{
|
|
2064
|
+
method: "POST",
|
|
2065
|
+
path: "ask",
|
|
2066
|
+
slow: true
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
method: "POST",
|
|
2070
|
+
path: "ask/:thread/:action{open|choice|permissions}",
|
|
2071
|
+
slow: true
|
|
2072
|
+
},
|
|
2073
|
+
{
|
|
2074
|
+
method: "POST",
|
|
2075
|
+
path: "ask/:thread/:action{prompt|permission|files|retry|cancel|close}"
|
|
2076
|
+
},
|
|
2077
|
+
{
|
|
2078
|
+
method: "DELETE",
|
|
2079
|
+
path: "ask/:thread"
|
|
2080
|
+
}
|
|
2081
|
+
];
|
|
2082
|
+
const SLOW_ROUTE_TIMEOUT_MS = 12e4;
|
|
2083
|
+
const LIST_WAIT_MS = 2e3;
|
|
2084
|
+
const HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
2085
|
+
"connection",
|
|
2086
|
+
"keep-alive",
|
|
2087
|
+
"transfer-encoding",
|
|
2088
|
+
"content-length",
|
|
2089
|
+
"te",
|
|
2090
|
+
"trailer",
|
|
2091
|
+
"upgrade"
|
|
2092
|
+
]);
|
|
2093
|
+
const DROPPED_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
2094
|
+
...HOP_HEADERS,
|
|
2095
|
+
"host",
|
|
2096
|
+
"origin",
|
|
2097
|
+
"accept-encoding",
|
|
2098
|
+
"cookie",
|
|
2099
|
+
"authorization",
|
|
2100
|
+
"x-review-token",
|
|
2101
|
+
REVIEW_CLIENT_HEADER,
|
|
2102
|
+
REVIEW_HOST_HEADER
|
|
2103
|
+
]);
|
|
2104
|
+
const commandTarget = z.object({ operation: z.object({ reviewId: z.string().optional() }).optional() });
|
|
2105
|
+
function createReviewGateway(input) {
|
|
2106
|
+
const log = input.log ?? (() => {});
|
|
2107
|
+
const memory = openGatewayMemory(input.home, log);
|
|
2108
|
+
let changing = false;
|
|
2109
|
+
let closed = false;
|
|
2110
|
+
const hosts = createGatewayHosts({
|
|
2111
|
+
version: input.version,
|
|
2112
|
+
log,
|
|
2113
|
+
machine: (serverId, alias) => memory.rename(serverId, alias),
|
|
2114
|
+
...input.heartbeatMs !== void 0 && { heartbeatMs: input.heartbeatMs },
|
|
2115
|
+
restarted: (alias) => input.restarted?.(alias),
|
|
2116
|
+
changed() {
|
|
2117
|
+
if (changing) return;
|
|
2118
|
+
changing = true;
|
|
2119
|
+
queueMicrotask(() => {
|
|
2120
|
+
changing = false;
|
|
2121
|
+
if (closed) return;
|
|
2122
|
+
const online = new Set(hosts.online());
|
|
2123
|
+
for (const [abort, remote] of streaming) if (!online.has(remote)) abort.abort();
|
|
2124
|
+
streams.changed();
|
|
2125
|
+
pushes.changed();
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
const streaming = /* @__PURE__ */ new Map();
|
|
2130
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
2131
|
+
const lists = () => ({
|
|
2132
|
+
states: hosts.states(),
|
|
2133
|
+
serving: (serverId) => hosts.serving(serverId) !== void 0,
|
|
2134
|
+
serverIdOf: memory.serverIdOf,
|
|
2135
|
+
list: memory.list
|
|
2136
|
+
});
|
|
2137
|
+
function locate(reviewId) {
|
|
2138
|
+
if (!UUID.test(reviewId) || input.isLocal(reviewId)) return "local";
|
|
2139
|
+
const listed = listedBy(reviewId, lists());
|
|
2140
|
+
if (!listed) return void 0;
|
|
2141
|
+
const remote = hosts.serving(listed.serverId);
|
|
2142
|
+
return remote ? { remote } : { down: listed.state };
|
|
2143
|
+
}
|
|
2144
|
+
function ownerOf(reviewId) {
|
|
2145
|
+
const located = locate(reviewId);
|
|
2146
|
+
if (located !== void 0 || !hosts.online().length) return located;
|
|
2147
|
+
const found = Promise.withResolvers();
|
|
2148
|
+
const settle = (owner) => {
|
|
2149
|
+
clearTimeout(timer);
|
|
2150
|
+
waiting.delete(check);
|
|
2151
|
+
found.resolve(owner);
|
|
2152
|
+
};
|
|
2153
|
+
const check = () => {
|
|
2154
|
+
const owner = locate(reviewId);
|
|
2155
|
+
if (owner !== void 0) settle(owner);
|
|
2156
|
+
};
|
|
2157
|
+
const timer = setTimeout(() => settle(void 0), LIST_WAIT_MS);
|
|
2158
|
+
waiting.add(check);
|
|
2159
|
+
return found.promise;
|
|
2160
|
+
}
|
|
2161
|
+
function answer(alias, status, body) {
|
|
2162
|
+
const response = serverJson(status, body);
|
|
2163
|
+
response.headers.set(REVIEW_HOST_HEADER, alias);
|
|
2164
|
+
return response;
|
|
2165
|
+
}
|
|
2166
|
+
function unavailable(down) {
|
|
2167
|
+
return answer(down.alias, 503, {
|
|
2168
|
+
ok: false,
|
|
2169
|
+
error: downDetail(down)
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
async function forward(remote, request, options) {
|
|
2173
|
+
const url = new URL(request.url);
|
|
2174
|
+
const { route } = options;
|
|
2175
|
+
if (url.searchParams.has("token")) url.searchParams.delete("token");
|
|
2176
|
+
const headers = remoteHeaders(remote);
|
|
2177
|
+
request.headers.forEach((value, key) => {
|
|
2178
|
+
if (!DROPPED_REQUEST_HEADERS.has(key)) headers[key] = value;
|
|
2179
|
+
});
|
|
2180
|
+
const limit = route?.slow ? input.slowRouteMs ?? SLOW_ROUTE_TIMEOUT_MS : FIRST_BYTE_TIMEOUT_MS;
|
|
2181
|
+
const abort = new AbortController();
|
|
2182
|
+
const stream = request.body;
|
|
2183
|
+
let response;
|
|
2184
|
+
try {
|
|
2185
|
+
response = await send(remote, {
|
|
2186
|
+
method: request.method,
|
|
2187
|
+
path: `${url.pathname}${url.search}`,
|
|
2188
|
+
headers,
|
|
2189
|
+
body: options.body ?? (stream ? Readable.fromWeb(stream) : void 0),
|
|
2190
|
+
signal: AbortSignal.any([request.signal, abort.signal]),
|
|
2191
|
+
headersTimeout: limit
|
|
2192
|
+
});
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
const timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
|
|
2195
|
+
const reason = timedOut ? `it did not answer within ${limit / 1e3} seconds` : errorText(error);
|
|
2196
|
+
if (!route?.slow && !request.signal.aborted) hosts.failed(remote, reason);
|
|
2197
|
+
return answer(remote.alias, timedOut ? 504 : 502, {
|
|
2198
|
+
ok: false,
|
|
2199
|
+
error: `Could not reach ${remote.alias}: ${reason}.`
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
const { statusCode: status } = response;
|
|
2203
|
+
const out = new Headers();
|
|
2204
|
+
for (const [key, value] of Object.entries(response.headers)) if (value !== void 0 && !HOP_HEADERS.has(key) && !key.startsWith("access-control-")) out.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
2205
|
+
out.set(REVIEW_HOST_HEADER, remote.alias);
|
|
2206
|
+
if (request.method === "HEAD" || status === 204 || status === 304) {
|
|
2207
|
+
await response.body.dump();
|
|
2208
|
+
return new Response(null, {
|
|
2209
|
+
status,
|
|
2210
|
+
headers: out
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
streaming.set(abort, remote);
|
|
2214
|
+
response.body.on("close", () => streaming.delete(abort));
|
|
2215
|
+
const piped = Readable.toWeb(response.body);
|
|
2216
|
+
return new Response(piped, {
|
|
2217
|
+
status,
|
|
2218
|
+
headers: out
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
async function command(request) {
|
|
2222
|
+
if (!request.body) return input.local(request);
|
|
2223
|
+
let body;
|
|
2224
|
+
try {
|
|
2225
|
+
body = await readBoundedStream(request.body, DEFAULT_MAX_REQUEST_BYTES);
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
if (!(error instanceof StreamLimitError)) throw error;
|
|
2228
|
+
return serverJson(413, {
|
|
2229
|
+
ok: false,
|
|
2230
|
+
error: "Request body exceeds 1 MiB."
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
let reviewId;
|
|
2234
|
+
try {
|
|
2235
|
+
reviewId = commandTarget.safeParse(parseJsonText(body.toString())).data?.operation?.reviewId;
|
|
2236
|
+
} catch {}
|
|
2237
|
+
const owner = reviewId === void 0 ? void 0 : await ownerOf(reviewId);
|
|
2238
|
+
if (owner === void 0 || owner === "local") return input.local(new Request(request.url, {
|
|
2239
|
+
method: request.method,
|
|
2240
|
+
headers: request.headers,
|
|
2241
|
+
body: new Uint8Array(body),
|
|
2242
|
+
signal: request.signal
|
|
2243
|
+
}));
|
|
2244
|
+
if ("down" in owner) return unavailable(owner.down);
|
|
2245
|
+
return forward(owner.remote, request, { body });
|
|
2246
|
+
}
|
|
2247
|
+
async function review(request, reviewId, route) {
|
|
2248
|
+
if (!reviewId || LOCAL_ROUTES.has(reviewId)) return input.local(request);
|
|
2249
|
+
const owner = await ownerOf(reviewId);
|
|
2250
|
+
if (owner === void 0 || owner === "local") return input.local(request);
|
|
2251
|
+
const alias = "remote" in owner ? owner.remote.alias : owner.down.alias;
|
|
2252
|
+
if (route === "telemetry") {
|
|
2253
|
+
await request.body?.cancel();
|
|
2254
|
+
return answer(alias, 200, { ok: true });
|
|
2255
|
+
}
|
|
2256
|
+
if (!route) return answer(alias, 404, {
|
|
2257
|
+
ok: false,
|
|
2258
|
+
error: "This route is not available for a review on another machine."
|
|
2259
|
+
});
|
|
2260
|
+
if ("down" in owner) return unavailable(owner.down);
|
|
2261
|
+
return forward(owner.remote, request, { route });
|
|
2262
|
+
}
|
|
2263
|
+
const app = new Hono({ strict: false }).basePath("/reviews-api");
|
|
2264
|
+
app.post("/commands", (context) => command(context.req.raw));
|
|
2265
|
+
app.get("/", (context) => streams.list(context.req.raw));
|
|
2266
|
+
app.get("/watch", (context) => streams.watch(context.req.raw));
|
|
2267
|
+
app.all("/:id/telemetry/*", (context) => review(context.req.raw, context.req.param("id"), "telemetry"));
|
|
2268
|
+
for (const route of REMOTE_ROUTES) app.on(route.method, `/:id${route.path && `/${route.path}`}`, (context) => review(context.req.raw, context.req.param("id") ?? "", route));
|
|
2269
|
+
for (const path of ["/:id", "/:id/*"]) app.all(path, (context) => review(context.req.raw, context.req.param("id") ?? ""));
|
|
2270
|
+
app.all("*", (context) => input.local(context.req.raw));
|
|
2271
|
+
const streams = createGatewayStreams({
|
|
2272
|
+
hosts,
|
|
2273
|
+
memory,
|
|
2274
|
+
local: input.local,
|
|
2275
|
+
lists,
|
|
2276
|
+
locate,
|
|
2277
|
+
listed() {
|
|
2278
|
+
for (const check of waiting) check();
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
async function claim(remote, reviewId) {
|
|
2282
|
+
if (await ownerOf(reviewId) === void 0) return `${reviewId} is not on ${remote.alias}'s list.`;
|
|
2283
|
+
}
|
|
2284
|
+
const pushes = createGatewayPushes({
|
|
2285
|
+
hosts,
|
|
2286
|
+
relay: input.relay,
|
|
2287
|
+
claim,
|
|
2288
|
+
log
|
|
2289
|
+
});
|
|
2290
|
+
const stopWatchingWindows = input.relay.onAttachedChange(() => {
|
|
2291
|
+
if (!closed) pushes.changed();
|
|
2292
|
+
});
|
|
2293
|
+
return {
|
|
2294
|
+
fetch: (request) => app.fetch(request),
|
|
2295
|
+
setHosts: (list) => hosts.set(list),
|
|
2296
|
+
hosts: () => hosts.states(),
|
|
2297
|
+
async close() {
|
|
2298
|
+
closed = true;
|
|
2299
|
+
stopWatchingWindows();
|
|
2300
|
+
streams.close();
|
|
2301
|
+
pushes.close();
|
|
2302
|
+
hosts.close();
|
|
2303
|
+
await memory.flush();
|
|
2304
|
+
}
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
//#endregion
|
|
729
2308
|
//#region src/server/review-lifecycle-telemetry.ts
|
|
730
2309
|
/**
|
|
731
2310
|
* Review created, published and revoked, and agent authoring completed, as
|
|
@@ -808,154 +2387,7 @@ var ReviewOpenWatchdog = class {
|
|
|
808
2387
|
}
|
|
809
2388
|
};
|
|
810
2389
|
//#endregion
|
|
811
|
-
//#region src/server/tutorial-service.ts
|
|
812
|
-
const authoredSchema = z.strictObject({
|
|
813
|
-
title: z.string().min(1),
|
|
814
|
-
document: documentSchema
|
|
815
|
-
});
|
|
816
|
-
const lensesSchema = z.array(lensSchema.omit({ id: true }));
|
|
817
|
-
const pinsSchema = z.strictObject({
|
|
818
|
-
base: z.string().regex(/^[a-f0-9]{40}$/),
|
|
819
|
-
head: z.string().regex(/^[a-f0-9]{40}$/)
|
|
820
|
-
});
|
|
821
|
-
const stampSchema = z.object({
|
|
822
|
-
version: z.literal(10),
|
|
823
|
-
reviewUuid: z.string(),
|
|
824
|
-
contentHash: z.string()
|
|
825
|
-
});
|
|
826
|
-
async function readTutorialAssets(assetsRoot) {
|
|
827
|
-
const bytes = await Promise.all([
|
|
828
|
-
"document.json",
|
|
829
|
-
"lenses.json",
|
|
830
|
-
"pins.json"
|
|
831
|
-
].map((name) => readFile(path.join(assetsRoot, name), "utf8")));
|
|
832
|
-
return {
|
|
833
|
-
authored: authoredSchema.parse(JSON.parse(bytes[0])),
|
|
834
|
-
lenses: lensesSchema.parse(JSON.parse(bytes[1])),
|
|
835
|
-
pins: pinsSchema.parse(JSON.parse(bytes[2])),
|
|
836
|
-
contentHash: createHash("sha256").update(JSON.stringify(bytes)).digest("hex")
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
/** Create the shipped document through the same strict validation as authored reviews. */
|
|
840
|
-
async function createNativeTutorial(input) {
|
|
841
|
-
const assets = await readTutorialAssets(input.assetsRoot);
|
|
842
|
-
const repository = await input.data.register(input.sampleRoot);
|
|
843
|
-
const pins = await input.data.resolvePins(repository.id, assets.pins.base, assets.pins.head);
|
|
844
|
-
const result = await input.store.execute({ operation: {
|
|
845
|
-
type: "create",
|
|
846
|
-
title: assets.authored.title,
|
|
847
|
-
target: {
|
|
848
|
-
kind: "commits",
|
|
849
|
-
...pins
|
|
850
|
-
}
|
|
851
|
-
} }, {
|
|
852
|
-
document: assets.authored.document,
|
|
853
|
-
origin: { tutorial: true }
|
|
854
|
-
});
|
|
855
|
-
for (const { title, targets } of assets.lenses) await input.store.execute({ operation: {
|
|
856
|
-
type: "lens_edit",
|
|
857
|
-
reviewId: result.reviewId,
|
|
858
|
-
edit: {
|
|
859
|
-
type: "insert",
|
|
860
|
-
title,
|
|
861
|
-
targets
|
|
862
|
-
}
|
|
863
|
-
} });
|
|
864
|
-
return {
|
|
865
|
-
snapshot: input.store.read(result.reviewId),
|
|
866
|
-
contentHash: assets.contentHash
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
function createTutorialService(input) {
|
|
870
|
-
const tutorialRoot = path.join(devReviewHome(), "tutorial");
|
|
871
|
-
const sampleRoot = path.join(tutorialRoot, "sample-service");
|
|
872
|
-
const stampPath = path.join(tutorialRoot, "stamp.json");
|
|
873
|
-
const assetsRoot = path.join(input.packageRoot, "tutorial");
|
|
874
|
-
const readStamp = async () => stampSchema.safeParse(await readFile(stampPath, "utf8").then((value) => JSON.parse(value)).catch(() => null)).data;
|
|
875
|
-
async function find() {
|
|
876
|
-
try {
|
|
877
|
-
const stamp = await readStamp();
|
|
878
|
-
if (!stamp || !input.store.has(stamp.reviewUuid)) return null;
|
|
879
|
-
const snapshot = input.store.read(stamp.reviewUuid);
|
|
880
|
-
const assets = await readTutorialAssets(assetsRoot);
|
|
881
|
-
const [head, base] = await Promise.all([resolveRevision(sampleRoot, "HEAD"), resolveRevision(sampleRoot, "HEAD^")]);
|
|
882
|
-
return snapshot.origin?.tutorial && stamp.contentHash === assets.contentHash && head && base && snapshot.pins?.head === head.commit && snapshot.pins?.base === base.commit && head.commit === assets.pins.head && base.commit === assets.pins.base ? snapshot : null;
|
|
883
|
-
} catch {
|
|
884
|
-
return null;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
async function cleanup() {
|
|
888
|
-
for (const reviewId of input.store.tutorialIds()) {
|
|
889
|
-
if (!input.store.read(reviewId).trashedAt) await input.store.execute({ operation: {
|
|
890
|
-
type: "lifecycle",
|
|
891
|
-
state: "trash",
|
|
892
|
-
reviewId
|
|
893
|
-
} });
|
|
894
|
-
await input.store.execute({ operation: {
|
|
895
|
-
type: "delete",
|
|
896
|
-
reviewId
|
|
897
|
-
} });
|
|
898
|
-
}
|
|
899
|
-
for (const repository of input.store.repositories()) {
|
|
900
|
-
const relative = path.relative(tutorialRoot, repository.path);
|
|
901
|
-
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) await input.data.forgetRepository(repository.id);
|
|
902
|
-
}
|
|
903
|
-
await rm(tutorialRoot, {
|
|
904
|
-
recursive: true,
|
|
905
|
-
force: true,
|
|
906
|
-
maxRetries: 10
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
return {
|
|
910
|
-
async status() {
|
|
911
|
-
return {
|
|
912
|
-
version: 1,
|
|
913
|
-
reviewUuid: (await find())?.reviewId ?? null
|
|
914
|
-
};
|
|
915
|
-
},
|
|
916
|
-
async referencesReview(id) {
|
|
917
|
-
return input.store.has(id) && input.store.read(id).origin?.tutorial === true;
|
|
918
|
-
},
|
|
919
|
-
async prepare(options) {
|
|
920
|
-
const current = await find();
|
|
921
|
-
if (current) return current;
|
|
922
|
-
await readTutorialAssets(assetsRoot);
|
|
923
|
-
await options?.beforeReset();
|
|
924
|
-
await cleanup();
|
|
925
|
-
await mkdir(tutorialRoot, {
|
|
926
|
-
recursive: true,
|
|
927
|
-
mode: 448
|
|
928
|
-
});
|
|
929
|
-
const temporaryRoot = path.join(tutorialRoot, `.sample-service-${randomUUID()}`);
|
|
930
|
-
try {
|
|
931
|
-
await cp(path.join(assetsRoot, "sample-service"), temporaryRoot, { recursive: true });
|
|
932
|
-
await cp(path.join(assetsRoot, "git-stub"), path.join(temporaryRoot, ".git"), { recursive: true });
|
|
933
|
-
await rename(temporaryRoot, sampleRoot);
|
|
934
|
-
const { snapshot, contentHash } = await createNativeTutorial({
|
|
935
|
-
assetsRoot,
|
|
936
|
-
sampleRoot,
|
|
937
|
-
store: input.store,
|
|
938
|
-
data: input.data
|
|
939
|
-
});
|
|
940
|
-
await writePrivateJsonAtomic(stampPath, {
|
|
941
|
-
version: 10,
|
|
942
|
-
reviewUuid: snapshot.reviewId,
|
|
943
|
-
contentHash
|
|
944
|
-
});
|
|
945
|
-
return snapshot;
|
|
946
|
-
} finally {
|
|
947
|
-
await rm(temporaryRoot, {
|
|
948
|
-
recursive: true,
|
|
949
|
-
force: true
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
},
|
|
953
|
-
cleanup
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
//#endregion
|
|
957
2390
|
//#region src/server/desktop-server.ts
|
|
958
|
-
const TUTORIAL_LIFECYCLE_LOCK_KEY = "tutorial-lifecycle";
|
|
959
2391
|
function createGlobalReviewServer(input) {
|
|
960
2392
|
const instanceId = input.instanceId ?? crypto.randomUUID();
|
|
961
2393
|
const token = input.token ?? crypto.randomBytes(32).toString("base64url");
|
|
@@ -968,42 +2400,13 @@ function createGlobalReviewServer(input) {
|
|
|
968
2400
|
const discoveryPaths = input.discoveryPath ? [input.discoveryPath] : identity.key === "stable" ? [reviewInstanceDiscoveryPath("stable"), reviewLegacyDiscoveryPath()] : [reviewInstanceDiscoveryPath(identity.key)];
|
|
969
2401
|
const telemetry = input.telemetry ?? ReviewTelemetry.fromEnv();
|
|
970
2402
|
const relay = input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
},
|
|
976
|
-
...process.versions.electron ? [{
|
|
977
|
-
name: "ELECTRON_RUN_AS_NODE",
|
|
978
|
-
value: "1"
|
|
979
|
-
}] : [],
|
|
980
|
-
...process.env.DEV_REVIEW_HOME ? [{
|
|
981
|
-
name: "DEV_REVIEW_HOME",
|
|
982
|
-
value: process.env.DEV_REVIEW_HOME
|
|
983
|
-
}] : []
|
|
984
|
-
];
|
|
985
|
-
const askTools = {
|
|
986
|
-
mcpServers: () => discovery.cliPath ? [{
|
|
987
|
-
name: "whiteboard",
|
|
988
|
-
command: process.execPath,
|
|
989
|
-
args: [discovery.cliPath, "mcp"],
|
|
990
|
-
env: askCliEnv()
|
|
991
|
-
}] : [],
|
|
992
|
-
cli: () => discovery.cliPath && [
|
|
993
|
-
...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
|
|
994
|
-
shellQuote(process.execPath),
|
|
995
|
-
shellQuote(discovery.cliPath)
|
|
996
|
-
].join(" ")
|
|
997
|
-
};
|
|
2403
|
+
const askTools = cliAskTools(() => discovery.cliPath, {
|
|
2404
|
+
name: REVIEW_INSTANCE_ENV,
|
|
2405
|
+
value: identity.key
|
|
2406
|
+
});
|
|
998
2407
|
const reviewStore = input.reviewStore;
|
|
999
|
-
const reviewLocks = /* @__PURE__ */ new Map();
|
|
1000
2408
|
let appReadyReported = false;
|
|
1001
2409
|
const openWatchdog = new ReviewOpenWatchdog({ onTimeout: (context, elapsedMs) => void telemetry.captureEvent("review_open_timeout", { elapsed_ms: elapsedMs }, context) });
|
|
1002
|
-
const tutorial = createTutorialService({
|
|
1003
|
-
packageRoot: input.packageRoot,
|
|
1004
|
-
store: reviewStore,
|
|
1005
|
-
data: input.reviewData
|
|
1006
|
-
});
|
|
1007
2410
|
let closing = false;
|
|
1008
2411
|
const cliPath = path.join(input.packageRoot, "dist", "cli.js");
|
|
1009
2412
|
let scratchpadEnabled = false;
|
|
@@ -1049,8 +2452,22 @@ function createGlobalReviewServer(input) {
|
|
|
1049
2452
|
ask: { tools: askTools }
|
|
1050
2453
|
});
|
|
1051
2454
|
const { app } = core;
|
|
1052
|
-
|
|
1053
|
-
|
|
2455
|
+
const localApi = new Hono();
|
|
2456
|
+
answerErrorsAsJson(localApi);
|
|
2457
|
+
localApi.route("/reviews-api", createJsonReviewReporting(input.reviewStore, telemetry, { shared: input.sharedReviews }));
|
|
2458
|
+
localApi.route("/reviews-api", core.api);
|
|
2459
|
+
const gateway = createReviewGateway({
|
|
2460
|
+
local: (request) => localApi.fetch(request),
|
|
2461
|
+
isLocal: (reviewId) => reviewStore.has(reviewId),
|
|
2462
|
+
version: readReviewPackageVersion(import.meta.url),
|
|
2463
|
+
home: devReviewHome(),
|
|
2464
|
+
relay,
|
|
2465
|
+
restarted: (alias) => input.onRemoteHostRestarted?.(alias),
|
|
2466
|
+
log: input.log
|
|
2467
|
+
});
|
|
2468
|
+
app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
|
|
2469
|
+
app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
|
|
2470
|
+
app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
|
|
1054
2471
|
app.get("/preferences/scratchpad", () => serverJson(200, { enabled: scratchpadEnabled }));
|
|
1055
2472
|
app.put("/preferences/scratchpad", async (context) => {
|
|
1056
2473
|
const request = z.object({ enabled: z.boolean() }).safeParse(await readBoundedRequestJson(context.req.raw));
|
|
@@ -1100,21 +2517,6 @@ function createGlobalReviewServer(input) {
|
|
|
1100
2517
|
const result = await reportCrashDump(telemetry, body.data, input.crashDumpsDir);
|
|
1101
2518
|
return serverJson(result.status, result.body);
|
|
1102
2519
|
});
|
|
1103
|
-
app.get("/tutorial/status", async () => serverJson(200, await tutorial.status()));
|
|
1104
|
-
app.post("/tutorial/prepare", async () => {
|
|
1105
|
-
const prepared = await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, prepareTutorialLocked);
|
|
1106
|
-
return serverJson(200, {
|
|
1107
|
-
ok: true,
|
|
1108
|
-
reviewUuid: prepared.reviewId
|
|
1109
|
-
});
|
|
1110
|
-
});
|
|
1111
|
-
app.post("/tutorial/open", async () => {
|
|
1112
|
-
return serverJson(200, await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, openTutorialLocked));
|
|
1113
|
-
});
|
|
1114
|
-
app.delete("/tutorial", async () => {
|
|
1115
|
-
await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, deleteTutorialLocked);
|
|
1116
|
-
return serverJson(200, { ok: true });
|
|
1117
|
-
});
|
|
1118
2520
|
app.get("/diffr-config", async () => serverJson(200, await readDiffrConfig()));
|
|
1119
2521
|
app.put("/diffr-config", async (context) => {
|
|
1120
2522
|
const body = await readBoundedRequestJson(context.req.raw);
|
|
@@ -1184,37 +2586,13 @@ function createGlobalReviewServer(input) {
|
|
|
1184
2586
|
await resetCliInstall();
|
|
1185
2587
|
return serverJson(200, { ok: true });
|
|
1186
2588
|
});
|
|
2589
|
+
app.get("/install/agents", async () => serverJson(200, { agents: await detectConnectAgents() }));
|
|
2590
|
+
app.post("/install/connect", async (context) => {
|
|
2591
|
+
const request = ReviewConnectRequestSchema.safeParse(await readBoundedRequestJson(context.req.raw));
|
|
2592
|
+
if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
|
|
2593
|
+
return serverJson(200, await connectAgent(request.data.target, { hasShim: existsSync(pathShimPath()) }));
|
|
2594
|
+
});
|
|
1187
2595
|
const httpServer = createServer(createNodeRequestListener(app));
|
|
1188
|
-
async function prepareTutorialLocked() {
|
|
1189
|
-
return tutorial.prepare();
|
|
1190
|
-
}
|
|
1191
|
-
async function openTutorialLocked() {
|
|
1192
|
-
const snapshot = await prepareTutorialLocked();
|
|
1193
|
-
return {
|
|
1194
|
-
kind: "api",
|
|
1195
|
-
reviewUuid: snapshot.reviewId,
|
|
1196
|
-
title: snapshot.title
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
async function deleteTutorialLocked() {
|
|
1200
|
-
await tutorial.cleanup();
|
|
1201
|
-
}
|
|
1202
|
-
async function withReviewLock(reviewUuid, operation) {
|
|
1203
|
-
const previous = reviewLocks.get(reviewUuid) ?? Promise.resolve();
|
|
1204
|
-
let release = () => {};
|
|
1205
|
-
const current = new Promise((resolve) => {
|
|
1206
|
-
release = resolve;
|
|
1207
|
-
});
|
|
1208
|
-
const chain = previous.then(() => current);
|
|
1209
|
-
reviewLocks.set(reviewUuid, chain);
|
|
1210
|
-
await previous;
|
|
1211
|
-
try {
|
|
1212
|
-
return await operation();
|
|
1213
|
-
} finally {
|
|
1214
|
-
release();
|
|
1215
|
-
if (reviewLocks.get(reviewUuid) === chain) reviewLocks.delete(reviewUuid);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
2596
|
return {
|
|
1219
2597
|
discovery,
|
|
1220
2598
|
get url() {
|
|
@@ -1222,6 +2600,10 @@ function createGlobalReviewServer(input) {
|
|
|
1222
2600
|
},
|
|
1223
2601
|
listen: async () => {
|
|
1224
2602
|
await migrateDiffrConfig();
|
|
2603
|
+
await removeLegacyTutorial({
|
|
2604
|
+
store: reviewStore,
|
|
2605
|
+
data: input.reviewData
|
|
2606
|
+
});
|
|
1225
2607
|
scratchpadEnabled = await readScratchpadEnabled();
|
|
1226
2608
|
boundPort = await listen(httpServer, input.port);
|
|
1227
2609
|
discovery.url = urlForBoundPort();
|
|
@@ -1235,8 +2617,10 @@ function createGlobalReviewServer(input) {
|
|
|
1235
2617
|
relay.close();
|
|
1236
2618
|
openWatchdog.dispose();
|
|
1237
2619
|
await closeHttpServer(httpServer);
|
|
2620
|
+
await gateway.close();
|
|
1238
2621
|
await telemetry.shutdown(1500);
|
|
1239
|
-
}
|
|
2622
|
+
},
|
|
2623
|
+
setRemoteHosts: (hosts) => gateway.setHosts(hosts)
|
|
1240
2624
|
};
|
|
1241
2625
|
}
|
|
1242
2626
|
function watchSessionOpen(watchdog, event, context) {
|
|
@@ -1332,25 +2716,38 @@ async function runDesktopHost(env = process.env) {
|
|
|
1332
2716
|
};
|
|
1333
2717
|
const home = devReviewHome(env);
|
|
1334
2718
|
await mkdir(home, { recursive: true });
|
|
1335
|
-
const
|
|
1336
|
-
const shared =
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
2719
|
+
const log = (message) => process.stdout.write(`[Whiteboard gateway] ${message}\n`);
|
|
2720
|
+
const { local, shared, server } = await takeOverServerHome(home, async () => {
|
|
2721
|
+
const local = await openReviewProfile(home, { manageWorkspaces: true });
|
|
2722
|
+
const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
|
|
2723
|
+
shared.connect(local.store, local.data);
|
|
2724
|
+
await shared.load();
|
|
2725
|
+
const server = createGlobalReviewServer({
|
|
2726
|
+
...serverInput,
|
|
2727
|
+
log,
|
|
2728
|
+
sharedReviews: shared,
|
|
2729
|
+
reviewStore: local.store,
|
|
2730
|
+
reviewData: local.data,
|
|
2731
|
+
cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
|
|
2732
|
+
crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR,
|
|
2733
|
+
onRemoteHostRestarted: (alias) => postToDesktop(process, {
|
|
2734
|
+
type: "remote-host-restarted",
|
|
2735
|
+
alias
|
|
2736
|
+
})
|
|
2737
|
+
});
|
|
2738
|
+
try {
|
|
2739
|
+
await server.listen();
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
await local?.data.close();
|
|
2742
|
+
await local?.store.close();
|
|
2743
|
+
throw error;
|
|
2744
|
+
}
|
|
2745
|
+
return {
|
|
2746
|
+
local,
|
|
2747
|
+
shared,
|
|
2748
|
+
server
|
|
2749
|
+
};
|
|
1346
2750
|
});
|
|
1347
|
-
try {
|
|
1348
|
-
await server.listen();
|
|
1349
|
-
} catch (error) {
|
|
1350
|
-
await local?.data.close();
|
|
1351
|
-
await local?.store.close();
|
|
1352
|
-
throw error;
|
|
1353
|
-
}
|
|
1354
2751
|
process.stdout.write(`${JSON.stringify({
|
|
1355
2752
|
event: "ready",
|
|
1356
2753
|
...server.discovery,
|
|
@@ -1373,7 +2770,7 @@ async function runDesktopHost(env = process.env) {
|
|
|
1373
2770
|
}, (sourcePath) => {
|
|
1374
2771
|
env.DEV_FAST_REVIEW_RUST_ANALYZER = sourcePath;
|
|
1375
2772
|
stageRustAnalyzer();
|
|
1376
|
-
});
|
|
2773
|
+
}, (hosts) => server.setRemoteHosts(hosts));
|
|
1377
2774
|
process.once("SIGINT", () => {
|
|
1378
2775
|
stop().then(() => process.exit(0));
|
|
1379
2776
|
});
|