@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as diffFileSummaries, b as resolveRevision, i as diff, x as splitGitPatchFiles } from "../src-CmdiBL20.js";
|
|
3
|
+
import { c as jsonString, i as jsonBoolean, m as isStringValue, n as isJsonObject, p as isObjectValue, s as jsonProperty, u as parseJsonText } from "../src-X9phtB2j.js";
|
|
4
|
+
import { i as SharedReviewStore } from "../client-DFoh50W3.js";
|
|
5
|
+
import { c as ReviewBugReportRequestSchema, l as parseReviewBugReportResponse, o as parseZod, s as ReviewBugReportMetaV2Schema, t as parseReviewCliInstallApplyRequest, v as reviewDiffrSummarizerInputSchema, y as decodeStructuralDiffEvent } from "../src-DnwdaQ2r.js";
|
|
6
|
+
import { _ as invalidateStructuralComparisons, b as installFullDiffr, v as diffrExecutable, y as diffrMissingError } from "../local-data-CxLk75rx.js";
|
|
7
|
+
import { C as resourceReferences, p as documentSchema } from "../stored-document-migration-DHeMpLMb.js";
|
|
8
|
+
import { t as ReviewInputError } from "../input-error-OLgB_h31.js";
|
|
9
|
+
import { A as devReviewHome, P as writePrivateJsonAtomic } from "../s3-config-CAxhO9u_.js";
|
|
10
|
+
import { U as shellQuote, i as reviewInstanceDiscoveryPath, o as reviewLegacyDiscoveryPath } from "../review-home-paths-6zZH-1c9.js";
|
|
11
|
+
import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-B6-zxvIO.js";
|
|
12
|
+
import { c as reviewInstanceIdentity, t as REVIEW_INSTANCE_ENV } from "../desktop-discovery-DBO-PM2V.js";
|
|
13
|
+
import { t as openReviewProfile } from "../profile-4ry2f3AM.js";
|
|
14
|
+
import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-DEFnQsQ2.js";
|
|
15
|
+
import { o as REVIEW_TELEMETRY_TABS } from "../request-origin-D_4QIdoN.js";
|
|
16
|
+
import { c as removeLegacyReviewSkills, d as skipCliInstall, i as finishCliInstallUpdate, l as resetCliInstall, n as applyCliInstall, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-DOHN1RSH.js";
|
|
17
|
+
import { a as serverJson, c as recordClientError, d as HttpJsonError, f as ReviewServerError, i as createWhiteboardCore, l as createNodeRequestListener, n as installProcessErrorTelemetry, o as captureSanitizedUiTelemetry, p as GlobalReviewDesktopVerbRelay, r as serverCrashReportPath, s as clientErrorsForSession, t as drainServerCrashReport, u as readBoundedRequestJson } from "../process-error-telemetry-v9e5h6D7.js";
|
|
18
|
+
import { c as readQuerySchemas } from "../authoring-tools-Cr6Kpjsx.js";
|
|
19
|
+
import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import fs, { existsSync } from "node:fs";
|
|
22
|
+
import { chmod, copyFile, cp, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
23
|
+
import path, { join } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
29
|
+
import { Writable } from "node:stream";
|
|
30
|
+
import { pipeline } from "node:stream/promises";
|
|
31
|
+
import { gzip, gzipSync } from "node:zlib";
|
|
32
|
+
import { createServer } from "node:http";
|
|
33
|
+
import { stringify } from "smol-toml";
|
|
34
|
+
import { Hono } from "hono";
|
|
35
|
+
//#region src/review-bundled-tools.ts
|
|
36
|
+
function reviewToolsRoot(env = process.env) {
|
|
37
|
+
return path.join(devReviewHome(env), "review-tools");
|
|
38
|
+
}
|
|
39
|
+
async function ensureBundledTool(input) {
|
|
40
|
+
if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/.test(input.tool)) throw new Error(`Invalid bundled tool name: ${input.tool}`);
|
|
41
|
+
const sourcePath = input.sourcePath?.trim();
|
|
42
|
+
if (!sourcePath) return "no-source";
|
|
43
|
+
const source = path.resolve(sourcePath);
|
|
44
|
+
if (!await isExecutableFile(source)) return "no-source";
|
|
45
|
+
const env = input.env ?? process.env;
|
|
46
|
+
const platform = reviewToolPlatform();
|
|
47
|
+
const executable = stagedToolPath(env, input.tool, platform);
|
|
48
|
+
const destination = path.dirname(executable);
|
|
49
|
+
const sha256 = await hashFile(source);
|
|
50
|
+
const identity = {
|
|
51
|
+
tool: input.tool,
|
|
52
|
+
platform,
|
|
53
|
+
sha256
|
|
54
|
+
};
|
|
55
|
+
if (installedToolMatches(destination, executable, identity)) return "fresh";
|
|
56
|
+
await mkdir(path.dirname(destination), {
|
|
57
|
+
recursive: true,
|
|
58
|
+
mode: 448
|
|
59
|
+
});
|
|
60
|
+
return await withInstallLock(destination, async () => {
|
|
61
|
+
if (installedToolMatches(destination, executable, identity)) return "fresh";
|
|
62
|
+
const staging = `${destination}.staging-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
63
|
+
await rm(staging, {
|
|
64
|
+
recursive: true,
|
|
65
|
+
force: true
|
|
66
|
+
});
|
|
67
|
+
await mkdir(staging, {
|
|
68
|
+
recursive: true,
|
|
69
|
+
mode: 448
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
const stagedExecutable = path.join(staging, executableName(input.tool, platform));
|
|
73
|
+
await copyFile(source, stagedExecutable);
|
|
74
|
+
if (await hashFile(stagedExecutable) !== sha256) throw new Error(`The bundled ${input.tool} source changed while Whiteboard staged it.`);
|
|
75
|
+
await chmod(stagedExecutable, 493);
|
|
76
|
+
await writeToolStamp(staging, identity);
|
|
77
|
+
await rm(destination, {
|
|
78
|
+
recursive: true,
|
|
79
|
+
force: true
|
|
80
|
+
});
|
|
81
|
+
await rename(staging, destination);
|
|
82
|
+
return "staged";
|
|
83
|
+
} finally {
|
|
84
|
+
await rm(staging, {
|
|
85
|
+
recursive: true,
|
|
86
|
+
force: true
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function ensureBundledRustAnalyzer(input = {}) {
|
|
92
|
+
const env = input.env ?? process.env;
|
|
93
|
+
return await ensureBundledTool({
|
|
94
|
+
tool: "rust-analyzer",
|
|
95
|
+
sourcePath: env.DEV_FAST_REVIEW_RUST_ANALYZER,
|
|
96
|
+
env
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function stagedToolPath(env, tool, platform = reviewToolPlatform()) {
|
|
100
|
+
return path.join(reviewToolsRoot(env), tool, "bundled", platform, executableName(tool, platform));
|
|
101
|
+
}
|
|
102
|
+
async function withInstallLock(destination, operation) {
|
|
103
|
+
const lockPath = `${destination}.install-lock`;
|
|
104
|
+
const deadline = Date.now() + 12e4;
|
|
105
|
+
while (true) try {
|
|
106
|
+
await mkdir(lockPath, { mode: 448 });
|
|
107
|
+
break;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
110
|
+
if (await stat(lockPath).then((value) => Date.now() - value.mtimeMs).catch(() => 0) > 6e5) {
|
|
111
|
+
await rm(lockPath, {
|
|
112
|
+
recursive: true,
|
|
113
|
+
force: true
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for another Whiteboard process to install ${path.basename(destination)}.`);
|
|
118
|
+
await setTimeout$1(100);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return await operation();
|
|
122
|
+
} finally {
|
|
123
|
+
await rm(lockPath, {
|
|
124
|
+
recursive: true,
|
|
125
|
+
force: true
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function installedToolMatches(destination, executable, identity) {
|
|
130
|
+
if (!isExecutable(executable)) return false;
|
|
131
|
+
try {
|
|
132
|
+
const stamp = parseJsonText(fs.readFileSync(path.join(destination, "review-tool.json"), "utf8"));
|
|
133
|
+
return isJsonObject(stamp) && stamp.tool === identity.tool && stamp.platform === identity.platform && stamp.sha256 === identity.sha256;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function writeToolStamp(destination, identity) {
|
|
139
|
+
await writeFile(path.join(destination, "review-tool.json"), `${JSON.stringify(identity, null, 2)}\n`, {
|
|
140
|
+
encoding: "utf8",
|
|
141
|
+
mode: 384
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function reviewToolPlatform() {
|
|
145
|
+
return `${process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform}-${process.arch}`;
|
|
146
|
+
}
|
|
147
|
+
function executableName(name, platform) {
|
|
148
|
+
return platform.startsWith("win32-") ? `${name}.exe` : name;
|
|
149
|
+
}
|
|
150
|
+
function isExecutable(filePath) {
|
|
151
|
+
try {
|
|
152
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function isExecutableFile(filePath) {
|
|
159
|
+
const metadata = await stat(filePath).catch(() => void 0);
|
|
160
|
+
return Boolean(metadata?.isFile() && isExecutable(filePath));
|
|
161
|
+
}
|
|
162
|
+
async function hashFile(filePath) {
|
|
163
|
+
const hash = createHash("sha256");
|
|
164
|
+
await pipeline(fs.createReadStream(filePath), new Writable({ write(chunk, _encoding, callback) {
|
|
165
|
+
hash.update(chunk);
|
|
166
|
+
callback();
|
|
167
|
+
} }));
|
|
168
|
+
return hash.digest("hex");
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/server/desktop-host-shutdown.ts
|
|
172
|
+
function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySetting, onStageRustAnalyzer) {
|
|
173
|
+
const handleMessage = (message) => {
|
|
174
|
+
if (!isJsonObject(message)) return;
|
|
175
|
+
const type = jsonProperty(message, "type");
|
|
176
|
+
if (type === "shutdown") {
|
|
177
|
+
onShutdown();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (type === "telemetry-setting") {
|
|
181
|
+
const enabled = jsonBoolean(jsonProperty(message, "enabled"));
|
|
182
|
+
if (enabled !== void 0) onTelemetrySetting?.(enabled);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (type === "stage-rust-analyzer") {
|
|
186
|
+
const path = jsonString(jsonProperty(message, "path"));
|
|
187
|
+
if (path) onStageRustAnalyzer?.(path);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
if (hostProcess.parentPort) {
|
|
191
|
+
const handleParentMessage = (event) => {
|
|
192
|
+
handleMessage(event.data);
|
|
193
|
+
};
|
|
194
|
+
hostProcess.parentPort.on("message", handleParentMessage);
|
|
195
|
+
return () => hostProcess.parentPort?.off("message", handleParentMessage);
|
|
196
|
+
}
|
|
197
|
+
hostProcess.on("message", handleMessage);
|
|
198
|
+
return () => hostProcess.off("message", handleMessage);
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/review-preferences.ts
|
|
202
|
+
const DEFAULT_REVIEW_PREFERENCES = { scratchpadEnabled: false };
|
|
203
|
+
function reviewPreferencesPath(devHome = devReviewHome()) {
|
|
204
|
+
return path.join(devHome, "preferences.json");
|
|
205
|
+
}
|
|
206
|
+
/** A missing or unreadable file falls back to the defaults; it never throws. */
|
|
207
|
+
async function readReviewPreferences(devHome) {
|
|
208
|
+
try {
|
|
209
|
+
return { scratchpadEnabled: parseScratchpadEnabled(parseJsonText(await readFile(reviewPreferencesPath(devHome), "utf8"))) };
|
|
210
|
+
} catch {
|
|
211
|
+
return { ...DEFAULT_REVIEW_PREFERENCES };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function writeReviewPreferences(preferences, devHome) {
|
|
215
|
+
const next = { scratchpadEnabled: preferences.scratchpadEnabled === true };
|
|
216
|
+
await writePrivateJsonAtomic(reviewPreferencesPath(devHome), next);
|
|
217
|
+
return next;
|
|
218
|
+
}
|
|
219
|
+
/** Reads the scratchpad preference; a missing file means off. */
|
|
220
|
+
async function readScratchpadEnabled(devHome) {
|
|
221
|
+
return (await readReviewPreferences(devHome)).scratchpadEnabled;
|
|
222
|
+
}
|
|
223
|
+
/** Sets the scratchpad preference, keeping the other preferences as they are. */
|
|
224
|
+
async function writeScratchpadEnabled(enabled, devHome) {
|
|
225
|
+
return (await writeReviewPreferences({
|
|
226
|
+
...await readReviewPreferences(devHome),
|
|
227
|
+
scratchpadEnabled: enabled
|
|
228
|
+
}, devHome)).scratchpadEnabled;
|
|
229
|
+
}
|
|
230
|
+
function parseScratchpadEnabled(raw) {
|
|
231
|
+
return isJsonObject(raw) && raw.scratchpadEnabled === true;
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/server/crash-report.ts
|
|
235
|
+
const CRASH_REPORT_URL = "https://bug.dev.fast/api/v2/crashes";
|
|
236
|
+
const gzipAsync = promisify(gzip);
|
|
237
|
+
/** The Worker's limit on the gzip dump it stores. */
|
|
238
|
+
const DEFAULT_MAX_DUMP_BYTES = 5e7;
|
|
239
|
+
/** Reading a dump this large into memory is not worth it; it can never fit. */
|
|
240
|
+
const MAX_RAW_DUMP_BYTES = 536870912;
|
|
241
|
+
const DUMP_TOO_LARGE = {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: 413,
|
|
244
|
+
error: "Crash dump is too large."
|
|
245
|
+
};
|
|
246
|
+
const UPSTREAM_TIMEOUT_MS$1 = 6e4;
|
|
247
|
+
const version = z.string().regex(/^[0-9A-Za-z.+_-]{1,64}$/);
|
|
248
|
+
/**
|
|
249
|
+
* The envelope keys the Worker's strict crash meta accepts, with the values it
|
|
250
|
+
* accepts. Any other key is dropped, and a value that would not pass is left
|
|
251
|
+
* out rather than failing the whole upload.
|
|
252
|
+
*/
|
|
253
|
+
const CrashEnvelopeSchema = z.object({
|
|
254
|
+
app_version: version.optional().catch(void 0),
|
|
255
|
+
cli_version: version.optional().catch(void 0),
|
|
256
|
+
channel: z.enum([
|
|
257
|
+
"stable",
|
|
258
|
+
"preview",
|
|
259
|
+
"dev"
|
|
260
|
+
]).optional().catch(void 0),
|
|
261
|
+
environment: z.enum([
|
|
262
|
+
"production",
|
|
263
|
+
"ci",
|
|
264
|
+
"internal",
|
|
265
|
+
"e2e",
|
|
266
|
+
"smoke"
|
|
267
|
+
]).optional().catch(void 0),
|
|
268
|
+
surface: z.enum([
|
|
269
|
+
"desktop",
|
|
270
|
+
"cli",
|
|
271
|
+
"headless",
|
|
272
|
+
"mcp",
|
|
273
|
+
"api"
|
|
274
|
+
]).optional().catch(void 0),
|
|
275
|
+
platform: ReviewBugReportMetaV2Schema.shape.platform.optional().catch(void 0),
|
|
276
|
+
arch: z.enum([
|
|
277
|
+
"arm",
|
|
278
|
+
"arm64",
|
|
279
|
+
"ia32",
|
|
280
|
+
"loong64",
|
|
281
|
+
"mips",
|
|
282
|
+
"mipsel",
|
|
283
|
+
"ppc",
|
|
284
|
+
"ppc64",
|
|
285
|
+
"riscv64",
|
|
286
|
+
"s390",
|
|
287
|
+
"s390x",
|
|
288
|
+
"x64"
|
|
289
|
+
]).optional().catch(void 0),
|
|
290
|
+
os_version: version.optional().catch(void 0),
|
|
291
|
+
node_major: z.number().int().optional().catch(void 0),
|
|
292
|
+
ci: z.boolean().optional().catch(void 0),
|
|
293
|
+
internal: z.boolean().optional().catch(void 0),
|
|
294
|
+
app_session_id: z.uuid().optional().catch(void 0)
|
|
295
|
+
});
|
|
296
|
+
/** The launch a dump was written during, from Electron main's dump ledger. */
|
|
297
|
+
const CrashLaunchSchema = CrashEnvelopeSchema.pick({
|
|
298
|
+
app_session_id: true,
|
|
299
|
+
app_version: true,
|
|
300
|
+
cli_version: true
|
|
301
|
+
});
|
|
302
|
+
/** The body Electron main posts to `/crash-reports`. */
|
|
303
|
+
const CrashReportRequestSchema = z.object({
|
|
304
|
+
dump_path: z.string().min(1),
|
|
305
|
+
crashed_at: z.number().int().nonnegative(),
|
|
306
|
+
covered: z.boolean(),
|
|
307
|
+
launch: CrashLaunchSchema.optional()
|
|
308
|
+
});
|
|
309
|
+
/**
|
|
310
|
+
* A dump belongs to the launch that wrote it, not to this one: its session id
|
|
311
|
+
* and versions override the current envelope. Without a matching launch the
|
|
312
|
+
* crashed session is unknown, so its id is left out; the versions stay the
|
|
313
|
+
* current launch's, the best guess there is.
|
|
314
|
+
*/
|
|
315
|
+
function crashedLaunchProperties(launch) {
|
|
316
|
+
const properties = { app_session_id: launch?.app_session_id };
|
|
317
|
+
if (!launch) return properties;
|
|
318
|
+
return {
|
|
319
|
+
...properties,
|
|
320
|
+
app_version: launch.app_version,
|
|
321
|
+
cli_version: launch.cli_version
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Upload one Crashpad minidump with the telemetry envelope as metadata. The
|
|
326
|
+
* caller has already checked the opt-out; this only packs and posts.
|
|
327
|
+
*/
|
|
328
|
+
async function uploadCrashDump(input) {
|
|
329
|
+
const maxDumpBytes = input.maxDumpBytes ?? DEFAULT_MAX_DUMP_BYTES;
|
|
330
|
+
if ((await stat(input.dumpPath)).size > MAX_RAW_DUMP_BYTES) return DUMP_TOO_LARGE;
|
|
331
|
+
const dump = await gzipAsync(await readFile(input.dumpPath), { level: 6 });
|
|
332
|
+
if (dump.byteLength > maxDumpBytes) return DUMP_TOO_LARGE;
|
|
333
|
+
const meta = {
|
|
334
|
+
schema_version: 1,
|
|
335
|
+
distinct_id: input.distinctId,
|
|
336
|
+
crashed_at: input.crashedAt,
|
|
337
|
+
covered: input.covered,
|
|
338
|
+
dump_bytes: dump.byteLength,
|
|
339
|
+
dump_sha256: createHash("sha256").update(dump).digest("hex"),
|
|
340
|
+
...CrashEnvelopeSchema.parse(input.envelope)
|
|
341
|
+
};
|
|
342
|
+
const form = new FormData();
|
|
343
|
+
form.append("meta", JSON.stringify(meta));
|
|
344
|
+
form.append("dump", new Blob([Uint8Array.from(dump)], { type: "application/gzip" }), "minidump.dmp.gz");
|
|
345
|
+
try {
|
|
346
|
+
const response = await (input.fetchImpl ?? fetch)(CRASH_REPORT_URL, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
body: form,
|
|
349
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS$1)
|
|
350
|
+
});
|
|
351
|
+
if (!response.ok) return {
|
|
352
|
+
ok: false,
|
|
353
|
+
status: response.status,
|
|
354
|
+
error: "Crash report service failed."
|
|
355
|
+
};
|
|
356
|
+
return { ok: true };
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
status: 502,
|
|
361
|
+
error: error instanceof Error ? error.message : "Crash report service failed."
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* `POST /crash-reports` from Electron main. The server token also reaches the
|
|
367
|
+
* canvas, so only a `.dmp` file whose real path sits inside the Review dump
|
|
368
|
+
* directory is ever read. A dump no live listener covered is counted here as
|
|
369
|
+
* `review_crash`, stamped with its own launch, which the `/telemetry/event`
|
|
370
|
+
* route cannot do: that route stamps the sender's session. Nothing is uploaded
|
|
371
|
+
* when telemetry is off or only printed (the debug sink).
|
|
372
|
+
*/
|
|
373
|
+
async function reportCrashDump(telemetry, request, crashDumpsDir, fetchImpl) {
|
|
374
|
+
const dumpPath = await dumpInside(request.dump_path, crashDumpsDir);
|
|
375
|
+
if (!dumpPath) return {
|
|
376
|
+
status: 403,
|
|
377
|
+
body: {
|
|
378
|
+
ok: false,
|
|
379
|
+
error: "Not a Whiteboard crash dump."
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const launch = crashedLaunchProperties(request.launch);
|
|
383
|
+
const counted = !request.covered;
|
|
384
|
+
if (counted) await telemetry.captureEvent("review_crash", {
|
|
385
|
+
process: "unknown",
|
|
386
|
+
reason: "minidump",
|
|
387
|
+
source: "minidump",
|
|
388
|
+
...launch
|
|
389
|
+
});
|
|
390
|
+
if (!await telemetry.sendsEvents()) return {
|
|
391
|
+
status: 200,
|
|
392
|
+
body: {
|
|
393
|
+
ok: true,
|
|
394
|
+
counted,
|
|
395
|
+
skipped: "telemetry_disabled"
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const result = await uploadCrashDump({
|
|
399
|
+
dumpPath,
|
|
400
|
+
crashedAt: request.crashed_at,
|
|
401
|
+
covered: request.covered,
|
|
402
|
+
distinctId: await telemetry.getInstallationId(),
|
|
403
|
+
envelope: {
|
|
404
|
+
...await telemetry.envelope(),
|
|
405
|
+
...launch
|
|
406
|
+
},
|
|
407
|
+
fetchImpl
|
|
408
|
+
});
|
|
409
|
+
return {
|
|
410
|
+
status: result.ok ? 200 : result.status ?? 502,
|
|
411
|
+
body: {
|
|
412
|
+
...result,
|
|
413
|
+
counted
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function dumpInside(file, directory) {
|
|
418
|
+
if (!directory) return void 0;
|
|
419
|
+
try {
|
|
420
|
+
const [realFile, realDirectory] = await Promise.all([realpath(file), realpath(directory)]);
|
|
421
|
+
const relative = path.relative(realDirectory, realFile);
|
|
422
|
+
if (!realFile.endsWith(".dmp") || !relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return void 0;
|
|
423
|
+
return realFile;
|
|
424
|
+
} catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/server/diffr-config.ts
|
|
430
|
+
const execFileAsync = promisify(execFile);
|
|
431
|
+
let writes = Promise.resolve();
|
|
432
|
+
let testing = false;
|
|
433
|
+
function serialized(operation) {
|
|
434
|
+
const next = writes.then(operation, operation);
|
|
435
|
+
writes = next.catch(() => {});
|
|
436
|
+
return next;
|
|
437
|
+
}
|
|
438
|
+
async function diffr(args, rootPath, options = {}) {
|
|
439
|
+
try {
|
|
440
|
+
const { stdout } = await execFileAsync(diffrExecutable(), args, {
|
|
441
|
+
cwd: rootPath,
|
|
442
|
+
maxBuffer: 16777216,
|
|
443
|
+
signal: options.signal ?? AbortSignal.timeout(3e4),
|
|
444
|
+
env: options.env ?? process.env
|
|
445
|
+
});
|
|
446
|
+
return stdout;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const failure = error;
|
|
449
|
+
if (failure.code === "ENOENT") throw diffrMissingError();
|
|
450
|
+
if (failure.name === "AbortError") throw new Error("diffr timed out or was cancelled.");
|
|
451
|
+
throw new Error("diffr could not complete the operation. Check its configuration and credentials.");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function values(rootPath, reveal = false, signal) {
|
|
455
|
+
return json(await diffr([
|
|
456
|
+
"config",
|
|
457
|
+
"show",
|
|
458
|
+
"--json",
|
|
459
|
+
...reveal ? ["--reveal"] : []
|
|
460
|
+
], rootPath, { signal }));
|
|
461
|
+
}
|
|
462
|
+
function json(output) {
|
|
463
|
+
try {
|
|
464
|
+
const parsed = parseJsonText(output);
|
|
465
|
+
if (isJsonObject(parsed)) return parsed;
|
|
466
|
+
} catch {}
|
|
467
|
+
throw new Error("diffr configuration response is malformed.");
|
|
468
|
+
}
|
|
469
|
+
function valueAt(object, key) {
|
|
470
|
+
let value = object;
|
|
471
|
+
for (const segment of key.split(".")) value = isJsonObject(value) ? value[segment] : void 0;
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
const prefix = "plugins.bundled.summarize";
|
|
475
|
+
function savedProvider(config) {
|
|
476
|
+
const value = valueAt(config, `${prefix}.provider`);
|
|
477
|
+
return isStringValue(value) ? value : "";
|
|
478
|
+
}
|
|
479
|
+
function savedEndpoint(config) {
|
|
480
|
+
const value = valueAt(config, `${prefix}.endpoint`);
|
|
481
|
+
return isStringValue(value) ? value : "";
|
|
482
|
+
}
|
|
483
|
+
/** Whether a saved key belongs to a different destination than the draft's. */
|
|
484
|
+
function movesKey(config, draft) {
|
|
485
|
+
return draft.provider !== savedProvider(config) || draft.endpoint !== savedEndpoint(config);
|
|
486
|
+
}
|
|
487
|
+
function environmentKey(providers, id) {
|
|
488
|
+
return providers.find((provider) => provider.id === id)?.keyVariables.map((name) => process.env[name]).find((key) => !!key);
|
|
489
|
+
}
|
|
490
|
+
function keyOptional(providers, id, endpoint) {
|
|
491
|
+
return endpoint !== "" && !!providers.find((provider) => provider.id === id)?.keylessCustomEndpoint;
|
|
492
|
+
}
|
|
493
|
+
function strings(value) {
|
|
494
|
+
return Array.isArray(value) ? value.filter(isStringValue) : [];
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* What diffr's schema says about summaries: each provider's defaults, and
|
|
498
|
+
* the default prompt with the link its description gives. Nothing when
|
|
499
|
+
* diffr cannot describe its schema: the rest of Settings still works.
|
|
500
|
+
*/
|
|
501
|
+
async function summaryDefaults(rootPath) {
|
|
502
|
+
let schema;
|
|
503
|
+
try {
|
|
504
|
+
schema = json(await diffr(["config", "schema"], rootPath));
|
|
505
|
+
} catch {
|
|
506
|
+
return {};
|
|
507
|
+
}
|
|
508
|
+
const options = valueAt(schema, "properties.plugins.properties.bundled.properties.summarize.properties");
|
|
509
|
+
if (!isJsonObject(options)) return {};
|
|
510
|
+
const option = (name) => isJsonObject(options[name]) ? options[name] : {};
|
|
511
|
+
const provider = option("provider");
|
|
512
|
+
const titles = strings(provider["x-enum-titles"]);
|
|
513
|
+
const models = valueAt(option("model"), "x-default-by.values");
|
|
514
|
+
const details = valueAt(option("provider_details"), "x-default-by.values");
|
|
515
|
+
const providers = strings(provider.enum).map((id, index) => {
|
|
516
|
+
const detail = isJsonObject(details) ? details[id] : void 0;
|
|
517
|
+
const model = isJsonObject(models) ? models[id] : void 0;
|
|
518
|
+
const endpoint = isJsonObject(detail) ? detail.endpoint : void 0;
|
|
519
|
+
return {
|
|
520
|
+
id,
|
|
521
|
+
title: titles[index] ?? id,
|
|
522
|
+
model: isStringValue(model) ? model : "",
|
|
523
|
+
endpoint: isStringValue(endpoint) ? endpoint : "",
|
|
524
|
+
keyVariables: isJsonObject(detail) ? strings(detail.key_variables) : [],
|
|
525
|
+
keylessCustomEndpoint: isJsonObject(detail) && detail.keyless_custom_endpoint === true
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
const prompt = option("system_prompt");
|
|
529
|
+
return {
|
|
530
|
+
providers,
|
|
531
|
+
defaultPrompt: isStringValue(prompt.default) ? prompt.default : void 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function read(rootPath) {
|
|
535
|
+
const config = await values(rootPath);
|
|
536
|
+
const defaults = await summaryDefaults(rootPath);
|
|
537
|
+
const saved = valueAt(config, `${prefix}.api_key`);
|
|
538
|
+
const credentialSource = isStringValue(saved) && saved.length > 0 ? "config" : environmentKey(defaults.providers ?? [], savedProvider(config)) ? "environment" : "missing";
|
|
539
|
+
const plugins = config.plugins;
|
|
540
|
+
if (isJsonObject(plugins)) for (const namespace of Object.values(plugins)) {
|
|
541
|
+
if (!isJsonObject(namespace)) continue;
|
|
542
|
+
for (const plugin of Object.values(namespace)) if (isJsonObject(plugin)) delete plugin.api_key;
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
values: config,
|
|
546
|
+
credentialSource,
|
|
547
|
+
...defaults
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function readDiffrConfig(rootPath) {
|
|
551
|
+
return serialized(() => read(rootPath));
|
|
552
|
+
}
|
|
553
|
+
function diffrConfigValueText(value) {
|
|
554
|
+
return isStringValue(value) ? value : JSON.stringify(value);
|
|
555
|
+
}
|
|
556
|
+
async function writeSettings(entries, rootPath) {
|
|
557
|
+
let changed = false;
|
|
558
|
+
let error;
|
|
559
|
+
let current = await values(rootPath);
|
|
560
|
+
try {
|
|
561
|
+
for (const [key, value] of entries) {
|
|
562
|
+
if (valueAt(current, key) === value) continue;
|
|
563
|
+
await diffr([
|
|
564
|
+
"config",
|
|
565
|
+
"set",
|
|
566
|
+
key,
|
|
567
|
+
diffrConfigValueText(value)
|
|
568
|
+
], rootPath);
|
|
569
|
+
changed = true;
|
|
570
|
+
current = await values(rootPath);
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
error = changed ? "Some settings were saved before diffr rejected a change. Check the current values and try again." : "diffr could not save the setting. Check its configuration and the entered value.";
|
|
574
|
+
} finally {
|
|
575
|
+
if (changed) invalidateStructuralComparisons();
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
...await read(rootPath),
|
|
579
|
+
changed,
|
|
580
|
+
error
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function setDiffrConfigValue(key, value, rootPath) {
|
|
584
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)*$/.test(key)) throw new Error("Invalid diffr config key.");
|
|
585
|
+
return serialized(() => writeSettings([[key, value]], rootPath));
|
|
586
|
+
}
|
|
587
|
+
function saveDiffrSummarizer(input, rootPath) {
|
|
588
|
+
const parsed = reviewDiffrSummarizerInputSchema.safeParse(input);
|
|
589
|
+
if (!parsed.success) throw new Error("Invalid summary settings.");
|
|
590
|
+
const draft = parsed.data;
|
|
591
|
+
return serialized(async () => {
|
|
592
|
+
const current = await read(rootPath);
|
|
593
|
+
const providers = current.providers ?? [];
|
|
594
|
+
const moving = movesKey(current.values, draft);
|
|
595
|
+
const savedKey = current.credentialSource === "config";
|
|
596
|
+
if (draft.enabled && !draft.apiKey && !(savedKey && !moving) && !environmentKey(providers, draft.provider) && !keyOptional(providers, draft.provider, draft.endpoint)) return {
|
|
597
|
+
...current,
|
|
598
|
+
changed: false,
|
|
599
|
+
error: "Add an API key before enabling summaries."
|
|
600
|
+
};
|
|
601
|
+
const entries = [];
|
|
602
|
+
if (!draft.enabled) entries.push([`${prefix}.enabled`, false]);
|
|
603
|
+
if (moving && savedKey) entries.push([`${prefix}.api_key`, ""]);
|
|
604
|
+
entries.push([`${prefix}.provider`, draft.provider]);
|
|
605
|
+
if (draft.endpoint !== savedEndpoint(current.values) || draft.endpoint && draft.provider !== savedProvider(current.values)) entries.push([`${prefix}.endpoint`, draft.endpoint]);
|
|
606
|
+
if (draft.apiKey) entries.push([`${prefix}.api_key`, draft.apiKey]);
|
|
607
|
+
if (draft.systemPrompt.trim() && draft.systemPrompt !== valueAt(current.values, `${prefix}.system_prompt`)) entries.push([`${prefix}.system_prompt`, draft.systemPrompt]);
|
|
608
|
+
entries.push([`${prefix}.model`, draft.model], [`${prefix}.tests`, draft.tests]);
|
|
609
|
+
if (draft.enabled) entries.push([`${prefix}.enabled`, true]);
|
|
610
|
+
return writeSettings(entries, rootPath);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
async function testDiffrSummarizer(input, rootPath, signal) {
|
|
614
|
+
const parsed = reviewDiffrSummarizerInputSchema.safeParse(input);
|
|
615
|
+
if (!parsed.success) throw new Error("Invalid summary settings.");
|
|
616
|
+
if (testing) throw new Error("A summary test is already running.");
|
|
617
|
+
testing = true;
|
|
618
|
+
const timeout = AbortSignal.timeout(6e4);
|
|
619
|
+
const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
620
|
+
let directory;
|
|
621
|
+
try {
|
|
622
|
+
const config = await serialized(() => values(rootPath, true, deadline));
|
|
623
|
+
const saved = valueAt(config, prefix);
|
|
624
|
+
if (!isJsonObject(saved)) throw new Error("The summarizer is not available in this configuration.");
|
|
625
|
+
const { provider, endpoint, model, systemPrompt } = parsed.data;
|
|
626
|
+
const providers = (await summaryDefaults(rootPath)).providers ?? [];
|
|
627
|
+
const variables = providers.find((known) => known.id === provider)?.keyVariables ?? [];
|
|
628
|
+
const apiKey = parsed.data.apiKey || (!movesKey(config, parsed.data) && isStringValue(saved.api_key) ? saved.api_key : "") || environmentKey(providers, provider);
|
|
629
|
+
if (!apiKey && !keyOptional(providers, provider, endpoint)) throw new Error("Add an API key to test summaries.");
|
|
630
|
+
directory = await mkdtemp(join(tmpdir(), "review-summary-"));
|
|
631
|
+
const options = {
|
|
632
|
+
...saved,
|
|
633
|
+
enabled: true,
|
|
634
|
+
provider,
|
|
635
|
+
model,
|
|
636
|
+
...systemPrompt.trim() && { system_prompt: systemPrompt },
|
|
637
|
+
min_lines: 1,
|
|
638
|
+
retries: 0,
|
|
639
|
+
request_timeout_ms: 45e3
|
|
640
|
+
};
|
|
641
|
+
delete options.api_key;
|
|
642
|
+
delete options.provider_details;
|
|
643
|
+
if (endpoint) options.endpoint = endpoint;
|
|
644
|
+
else delete options.endpoint;
|
|
645
|
+
await mkdir(join(directory, "diffr"));
|
|
646
|
+
await writeFile(join(directory, "diffr", "config.toml"), stringify({
|
|
647
|
+
version: 1,
|
|
648
|
+
plugins: {
|
|
649
|
+
order: ["bundled.summarize"],
|
|
650
|
+
bundled: { summarize: options }
|
|
651
|
+
}
|
|
652
|
+
}), { mode: 384 });
|
|
653
|
+
const before = join(directory, "before.rs"), after = join(directory, "after.rs");
|
|
654
|
+
await writeFile(before, "");
|
|
655
|
+
await writeFile(after, SUMMARY_SAMPLE);
|
|
656
|
+
const output = await diffr([
|
|
657
|
+
"--no-index",
|
|
658
|
+
"--format",
|
|
659
|
+
"ndjson",
|
|
660
|
+
"--stream-annotations",
|
|
661
|
+
"--",
|
|
662
|
+
before,
|
|
663
|
+
after
|
|
664
|
+
], directory, {
|
|
665
|
+
env: {
|
|
666
|
+
...process.env,
|
|
667
|
+
...Object.fromEntries(providers.flatMap((known) => known.keyVariables).map((name) => [name, ""])),
|
|
668
|
+
...apiKey && variables[0] && { [variables[0]]: apiKey },
|
|
669
|
+
XDG_CONFIG_HOME: directory
|
|
670
|
+
},
|
|
671
|
+
signal: deadline
|
|
672
|
+
});
|
|
673
|
+
try {
|
|
674
|
+
const events = output.trim().split("\n").map(decodeStructuralDiffEvent);
|
|
675
|
+
const complete = events.at(-1);
|
|
676
|
+
const annotation = events.flatMap((event) => event.type === "annotations" ? event.annotations : []).find((item) => item.label.trim());
|
|
677
|
+
if (complete?.type === "complete" && complete.failed === 0 && !complete.aborted && !events.some((event) => (event.type === "annotations" || event.type === "file") && event.error) && annotation) return apiKey ? annotation.label.split(apiKey).join("[redacted]") : annotation.label;
|
|
678
|
+
} catch {}
|
|
679
|
+
throw new Error("No summary was produced. Check the API key, model, and network connection.");
|
|
680
|
+
} finally {
|
|
681
|
+
testing = false;
|
|
682
|
+
if (directory) await rm(directory, {
|
|
683
|
+
recursive: true,
|
|
684
|
+
force: true
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const SUMMARY_SAMPLE = `pub fn count_values(values: &[i32]) -> (i32, i32) {
|
|
689
|
+
let mut total = 0;
|
|
690
|
+
let mut count = 0;
|
|
691
|
+
for value in values {
|
|
692
|
+
if *value > 0 {
|
|
693
|
+
total += value;
|
|
694
|
+
count += 1;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
let average = if count > 0 {
|
|
698
|
+
total / count
|
|
699
|
+
} else {
|
|
700
|
+
0
|
|
701
|
+
};
|
|
702
|
+
(count, average)
|
|
703
|
+
}
|
|
704
|
+
`;
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/review-diff-files.ts
|
|
707
|
+
async function resolveReviewDiffFiles(input) {
|
|
708
|
+
const baseRef = input.baseRef?.trim();
|
|
709
|
+
if (!baseRef) return { files: [] };
|
|
710
|
+
const headRef = input.headRef?.trim() || void 0;
|
|
711
|
+
const paths = normalizeDiffPaths(input.paths);
|
|
712
|
+
if (input.includePatch === false) return {
|
|
713
|
+
baseRef,
|
|
714
|
+
headRef,
|
|
715
|
+
files: await diffFileSummaries({
|
|
716
|
+
rootPath: input.rootPath,
|
|
717
|
+
baseRef,
|
|
718
|
+
headRef,
|
|
719
|
+
paths
|
|
720
|
+
})
|
|
721
|
+
};
|
|
722
|
+
const stdout = await diff({
|
|
723
|
+
rootPath: input.rootPath,
|
|
724
|
+
baseRef,
|
|
725
|
+
headRef,
|
|
726
|
+
paths,
|
|
727
|
+
contextLines: input.contextLines
|
|
728
|
+
});
|
|
729
|
+
return {
|
|
730
|
+
baseRef,
|
|
731
|
+
headRef,
|
|
732
|
+
files: splitGitPatchFiles(stdout).map(({ file, patch }) => ({
|
|
733
|
+
path: file.path,
|
|
734
|
+
previousPath: file.previousPath,
|
|
735
|
+
status: file.status,
|
|
736
|
+
additions: file.additions,
|
|
737
|
+
deletions: file.deletions,
|
|
738
|
+
patch
|
|
739
|
+
})).filter((file) => matchesDiffPath(file, paths))
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function normalizeDiffPaths(paths) {
|
|
743
|
+
return [...new Set((paths ?? []).map((path) => path.trim()).filter((path) => path.length > 0))].sort();
|
|
744
|
+
}
|
|
745
|
+
function matchesDiffPath(file, paths) {
|
|
746
|
+
if (paths.length === 0) return true;
|
|
747
|
+
const candidates = new Set([file.path, file.previousPath].filter(Boolean));
|
|
748
|
+
return paths.some((path) => candidates.has(path));
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/server/bug-report.ts
|
|
752
|
+
const BUG_REPORT_URL = "https://bug.dev.fast/api/v2/reports";
|
|
753
|
+
const MAX_PAYLOAD_BYTES = 10485760;
|
|
754
|
+
const UPSTREAM_TIMEOUT_MS = 2e4;
|
|
755
|
+
var BugReportUpstreamError = class extends Error {
|
|
756
|
+
status;
|
|
757
|
+
constructor(status, message) {
|
|
758
|
+
super(message);
|
|
759
|
+
this.status = status;
|
|
760
|
+
this.name = "BugReportUpstreamError";
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
async function submitReviewBugReport(input) {
|
|
764
|
+
const cliVersion = readReviewPackageVersion();
|
|
765
|
+
const attachmentErrors = [];
|
|
766
|
+
const payload = {
|
|
767
|
+
schema_version: 4,
|
|
768
|
+
description: input.report.description,
|
|
769
|
+
diagnostics: {
|
|
770
|
+
app_version: input.report.app_version,
|
|
771
|
+
cli_version: cliVersion,
|
|
772
|
+
platform: process.platform,
|
|
773
|
+
app_session_id: input.report.app_session_id,
|
|
774
|
+
client_error_names: input.clientErrorNames.slice(-20)
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
if (input.telemetryEnvelope) payload.diagnostics.telemetry = Object.fromEntries(Object.entries(input.telemetryEnvelope).filter((entry) => entry[1] !== void 0));
|
|
778
|
+
if (input.report.screenshot) payload.screenshot = input.report.screenshot;
|
|
779
|
+
let reviewSource;
|
|
780
|
+
let omittedReviewFiles;
|
|
781
|
+
let mapSource;
|
|
782
|
+
let changedFileDiffs;
|
|
783
|
+
const tasks = [];
|
|
784
|
+
if (input.report.include_review) tasks.push(input.source.review().then((result) => {
|
|
785
|
+
reviewSource = result.files;
|
|
786
|
+
if (result.omitted.length > 0) omittedReviewFiles = result.omitted;
|
|
787
|
+
}, () => {
|
|
788
|
+
attachmentErrors.push(unavailable("review"));
|
|
789
|
+
}));
|
|
790
|
+
if (input.report.include_map) tasks.push(input.source.map().then((source) => {
|
|
791
|
+
if (source !== null) mapSource = source;
|
|
792
|
+
}, () => {
|
|
793
|
+
attachmentErrors.push(unavailable("map"));
|
|
794
|
+
}));
|
|
795
|
+
if (input.report.include_diff) tasks.push(input.source.diff().then((diff) => {
|
|
796
|
+
changedFileDiffs = diff;
|
|
797
|
+
}, () => {
|
|
798
|
+
attachmentErrors.push(unavailable("diff"));
|
|
799
|
+
}));
|
|
800
|
+
await Promise.all(tasks);
|
|
801
|
+
if (reviewSource !== void 0) payload.review = reviewSource;
|
|
802
|
+
if (omittedReviewFiles !== void 0) payload.diagnostics.review_omitted_files = omittedReviewFiles;
|
|
803
|
+
if (mapSource !== void 0) payload.map = mapSource;
|
|
804
|
+
if (changedFileDiffs !== void 0) payload.diff = changedFileDiffs;
|
|
805
|
+
if (attachmentErrors.length > 0) payload.diagnostics.attachment_errors = attachmentErrors.sort(byAttachment);
|
|
806
|
+
const response = await (input.fetchImpl ?? fetch)(BUG_REPORT_URL, {
|
|
807
|
+
method: "POST",
|
|
808
|
+
body: buildBugReportRequest(payload, {
|
|
809
|
+
appVersion: input.report.app_version,
|
|
810
|
+
cliVersion
|
|
811
|
+
}),
|
|
812
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
813
|
+
}).catch((error) => {
|
|
814
|
+
throw new BugReportUpstreamError(502, error instanceof Error ? error.message : "Bug report service failed.");
|
|
815
|
+
});
|
|
816
|
+
const responseBody = await response.json().catch(() => null);
|
|
817
|
+
if (!response.ok) throw new BugReportUpstreamError(response.status === 429 || response.status === 413 ? response.status : 502, response.status === 429 ? "Too many reports. Try again later." : response.status === 413 ? "Bug report is too large." : "Bug report service failed.");
|
|
818
|
+
const result = parseReviewBugReportResponse(responseBody);
|
|
819
|
+
if (!result.ok) throw new BugReportUpstreamError(502, result.error);
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
function buildBugReportRequest(payload, input) {
|
|
823
|
+
const maxPayloadBytes = input.maxPayloadBytes ?? MAX_PAYLOAD_BYTES;
|
|
824
|
+
let truncatedDiff = false;
|
|
825
|
+
let truncatedMap = false;
|
|
826
|
+
let truncatedScreenshot = false;
|
|
827
|
+
let payloadBytes = gzipPayload(payload);
|
|
828
|
+
if (payloadBytes.byteLength > maxPayloadBytes && payload.diff) {
|
|
829
|
+
delete payload.diff;
|
|
830
|
+
truncatedDiff = true;
|
|
831
|
+
payloadBytes = gzipPayload(payload);
|
|
832
|
+
}
|
|
833
|
+
if (payloadBytes.byteLength > maxPayloadBytes && payload.map) {
|
|
834
|
+
delete payload.map;
|
|
835
|
+
truncatedMap = true;
|
|
836
|
+
payloadBytes = gzipPayload(payload);
|
|
837
|
+
}
|
|
838
|
+
if (payloadBytes.byteLength > maxPayloadBytes && payload.screenshot) {
|
|
839
|
+
delete payload.screenshot;
|
|
840
|
+
truncatedScreenshot = true;
|
|
841
|
+
payloadBytes = gzipPayload(payload);
|
|
842
|
+
}
|
|
843
|
+
if (payloadBytes.byteLength > maxPayloadBytes && payload.review) {
|
|
844
|
+
delete payload.review;
|
|
845
|
+
payloadBytes = gzipPayload(payload);
|
|
846
|
+
}
|
|
847
|
+
if (payloadBytes.byteLength > maxPayloadBytes) throw new BugReportUpstreamError(413, "Bug report is too large.");
|
|
848
|
+
const meta = {
|
|
849
|
+
schema_version: 2,
|
|
850
|
+
description_length: Buffer.byteLength(payload.description),
|
|
851
|
+
has_review: payload.review !== void 0,
|
|
852
|
+
has_map: payload.map !== void 0,
|
|
853
|
+
has_diff: payload.diff !== void 0,
|
|
854
|
+
has_screenshot: payload.screenshot !== void 0,
|
|
855
|
+
has_trace: false,
|
|
856
|
+
payload_bytes: payloadBytes.byteLength,
|
|
857
|
+
app_version: input.appVersion,
|
|
858
|
+
cli_version: input.cliVersion,
|
|
859
|
+
platform: process.platform,
|
|
860
|
+
truncated_diff: truncatedDiff,
|
|
861
|
+
truncated_map: truncatedMap,
|
|
862
|
+
truncated_screenshot: truncatedScreenshot,
|
|
863
|
+
truncated_trace: false,
|
|
864
|
+
parts: [{
|
|
865
|
+
field: "payload",
|
|
866
|
+
filename: "payload.json.gz",
|
|
867
|
+
bytes: payloadBytes.byteLength,
|
|
868
|
+
sha256: sha256Bytes(payloadBytes)
|
|
869
|
+
}]
|
|
870
|
+
};
|
|
871
|
+
if (payload.description.trim().length > 0) meta.description = payload.description;
|
|
872
|
+
const form = new FormData();
|
|
873
|
+
form.append("meta", JSON.stringify(meta));
|
|
874
|
+
form.append("payload", new Blob([Uint8Array.from(payloadBytes)], { type: "application/gzip" }), "payload.json.gz");
|
|
875
|
+
return form;
|
|
876
|
+
}
|
|
877
|
+
function gzipPayload(payload) {
|
|
878
|
+
return gzipSync(Buffer.from(JSON.stringify(payload)), { level: 9 });
|
|
879
|
+
}
|
|
880
|
+
function sha256Bytes(bytes) {
|
|
881
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
882
|
+
}
|
|
883
|
+
function unavailable(attachment) {
|
|
884
|
+
return {
|
|
885
|
+
attachment,
|
|
886
|
+
error: "unavailable"
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function byAttachment(left, right) {
|
|
890
|
+
return left.attachment.localeCompare(right.attachment);
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/server/review-api-parsers.ts
|
|
894
|
+
const MIN_REVIEW_TAB_DWELL_MS = 250;
|
|
895
|
+
const MAX_REVIEW_TAB_DWELL_MS = 144e5;
|
|
896
|
+
const APP_SESSION_ID_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9_.-]{15,127}$/;
|
|
897
|
+
const MAX_BUG_REPORT_DESCRIPTION_BYTES = 65536;
|
|
898
|
+
const MAX_BUG_REPORT_SCREENSHOT_BYTES = 3145728;
|
|
899
|
+
function parseReviewBugReportInput(value) {
|
|
900
|
+
const parsed = ReviewBugReportRequestSchema.parse(value);
|
|
901
|
+
if (Buffer.byteLength(parsed.description, "utf8") > MAX_BUG_REPORT_DESCRIPTION_BYTES) throw new HttpJsonError(`description must not exceed ${MAX_BUG_REPORT_DESCRIPTION_BYTES} UTF-8 bytes`, 413);
|
|
902
|
+
if (parsed.screenshot) {
|
|
903
|
+
const screenshot = Buffer.from(parsed.screenshot.base64, "base64");
|
|
904
|
+
if (screenshot.toString("base64") !== parsed.screenshot.base64) throw new HttpJsonError("screenshot.base64 must be valid base64", 400);
|
|
905
|
+
if (screenshot.byteLength > MAX_BUG_REPORT_SCREENSHOT_BYTES) throw new HttpJsonError(`screenshot must not exceed ${MAX_BUG_REPORT_SCREENSHOT_BYTES} decoded bytes`, 413);
|
|
906
|
+
}
|
|
907
|
+
return parsed;
|
|
908
|
+
}
|
|
909
|
+
const ReviewTabTelemetryInputSchema = z.strictObject({
|
|
910
|
+
tab: z.enum(REVIEW_TELEMETRY_TABS, { error: "must be review, commits, map, files, or trace" }),
|
|
911
|
+
reason: z.enum([
|
|
912
|
+
"tab_change",
|
|
913
|
+
"visibility_hidden",
|
|
914
|
+
"pagehide",
|
|
915
|
+
"unmount"
|
|
916
|
+
], { error: "must be tab_change, visibility_hidden, pagehide, or unmount" }),
|
|
917
|
+
duration_ms: z.number().int("must be an integer from 250ms to 4h").min(MIN_REVIEW_TAB_DWELL_MS, "must be an integer from 250ms to 4h").max(MAX_REVIEW_TAB_DWELL_MS, "must be an integer from 250ms to 4h"),
|
|
918
|
+
app_session_id: z.string().regex(APP_SESSION_ID_PATTERN, "must be a valid session id"),
|
|
919
|
+
document: z.unknown().optional()
|
|
920
|
+
}).transform((event) => ({
|
|
921
|
+
tab: event.tab,
|
|
922
|
+
reason: event.reason,
|
|
923
|
+
durationMs: event.duration_ms,
|
|
924
|
+
appSessionId: event.app_session_id
|
|
925
|
+
}));
|
|
926
|
+
function requestJsonErrorStatus(cause) {
|
|
927
|
+
return cause instanceof HttpJsonError ? cause.statusCode : 400;
|
|
928
|
+
}
|
|
929
|
+
function parseReviewTabTelemetryInput(value) {
|
|
930
|
+
return parseZod(ReviewTabTelemetryInputSchema, value, "Review tab telemetry event");
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/server/json-review-reporting.ts
|
|
934
|
+
function jsonReviewBugReportSource(store, snapshot) {
|
|
935
|
+
return {
|
|
936
|
+
async review() {
|
|
937
|
+
return {
|
|
938
|
+
files: { "review.json": JSON.stringify(snapshot, null, 2) },
|
|
939
|
+
omitted: []
|
|
940
|
+
};
|
|
941
|
+
},
|
|
942
|
+
async map() {
|
|
943
|
+
const ids = [...new Set(resourceReferences(snapshot.document).flatMap((block) => block.type === "software_map" ? [block.mapVersionId] : []))];
|
|
944
|
+
return ids.length ? JSON.stringify(ids.map((id) => JSON.parse(Buffer.from(store.resource(id).data).toString()))) : null;
|
|
945
|
+
},
|
|
946
|
+
async diff() {
|
|
947
|
+
if (!snapshot.pins) return { files: [] };
|
|
948
|
+
return resolveReviewDiffFiles({
|
|
949
|
+
rootPath: store.repositoryPath(snapshot.pins.repositoryId),
|
|
950
|
+
baseRef: snapshot.pins.base,
|
|
951
|
+
headRef: snapshot.pins.head,
|
|
952
|
+
includePatch: true
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** Telemetry and report uploads for a pinned native review. */
|
|
958
|
+
function createJsonReviewReporting(store, telemetry, options = {}) {
|
|
959
|
+
const { submit = submitReviewBugReport, shared } = options;
|
|
960
|
+
const app = new Hono();
|
|
961
|
+
app.onError((error, context) => context.json({
|
|
962
|
+
ok: false,
|
|
963
|
+
error: error.message
|
|
964
|
+
}, error instanceof BugReportUpstreamError ? error.status : error instanceof ReviewInputError ? error.status : requestJsonErrorStatus(error)));
|
|
965
|
+
app.use("/:id/telemetry/*", async (context, next) => {
|
|
966
|
+
const id = context.req.param("id");
|
|
967
|
+
if (id.startsWith("shared-")) {
|
|
968
|
+
if (!shared) throw new ReviewInputError("Shared review is not available.", 404);
|
|
969
|
+
shared.get(id);
|
|
970
|
+
} else store.assertExists(id);
|
|
971
|
+
await next();
|
|
972
|
+
});
|
|
973
|
+
app.post("/:id/telemetry/event", async (c) => {
|
|
974
|
+
const body = await readBoundedRequestJson(c.req.raw, void 0, {});
|
|
975
|
+
const payload = isJsonObject(body) ? body : {};
|
|
976
|
+
const rawContext = isJsonObject(payload.context) ? payload.context : {};
|
|
977
|
+
await captureSanitizedUiTelemetry(telemetry, c.req.raw, payload.name, payload.properties, recordClientError, payload.error, {
|
|
978
|
+
...rawContext,
|
|
979
|
+
reviewUuid: c.req.param("id")
|
|
980
|
+
}, payload.occurredAt);
|
|
981
|
+
return c.json({ ok: true });
|
|
982
|
+
});
|
|
983
|
+
app.post("/:id/telemetry/tab", async (context) => {
|
|
984
|
+
const input = await readBoundedRequestJson(context.req.raw, void 0, {}, { allowTextPlain: true });
|
|
985
|
+
await telemetry.captureTabViewed(parseReviewTabTelemetryInput(input), { reviewUuid: context.req.param("id") });
|
|
986
|
+
return context.json({ ok: true });
|
|
987
|
+
});
|
|
988
|
+
app.post("/:id/telemetry/bug-report", async (context) => {
|
|
989
|
+
const query = readQuerySchemas.get.parse({ version: context.req.query("version") });
|
|
990
|
+
const id = context.req.param("id");
|
|
991
|
+
const imported = id.startsWith("shared-") ? shared?.get(id) : void 0;
|
|
992
|
+
const snapshot = imported?.snapshot ?? store.read(id, query.version);
|
|
993
|
+
const report = parseReviewBugReportInput(await readBoundedRequestJson(context.req.raw, 6291456, {}));
|
|
994
|
+
return context.json(await submit({
|
|
995
|
+
report,
|
|
996
|
+
source: imported ? {
|
|
997
|
+
review: async () => ({
|
|
998
|
+
files: { "review.json": JSON.stringify(snapshot) },
|
|
999
|
+
omitted: []
|
|
1000
|
+
}),
|
|
1001
|
+
map: async () => JSON.stringify(imported.presentation.maps),
|
|
1002
|
+
diff: () => jsonReviewBugReportSource(store, snapshot).diff()
|
|
1003
|
+
} : jsonReviewBugReportSource(store, snapshot),
|
|
1004
|
+
clientErrorNames: clientErrorsForSession(report.app_session_id),
|
|
1005
|
+
telemetryEnvelope: await telemetry.envelope().catch(() => void 0)
|
|
1006
|
+
}));
|
|
1007
|
+
});
|
|
1008
|
+
return app;
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/server/review-lifecycle-telemetry.ts
|
|
1012
|
+
/**
|
|
1013
|
+
* Review created, published and revoked, and agent authoring completed, as
|
|
1014
|
+
* server events. Authoring is complete at the first publish of a review an
|
|
1015
|
+
* agent created through `whiteboard api` or `whiteboard mcp`, timed from the review's
|
|
1016
|
+
* creation. Which reviews an agent created is known only to this process, so
|
|
1017
|
+
* a server restart between create and publish loses that one completion.
|
|
1018
|
+
* Sign-in reports its funnel, and a success calls `onLoggedIn`.
|
|
1019
|
+
*/
|
|
1020
|
+
function reviewLifecycleTelemetry(telemetry, firstCreatedAt, onLoggedIn, now = Date.now) {
|
|
1021
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1022
|
+
const authoring = /* @__PURE__ */ new Map();
|
|
1023
|
+
const capture = (event, properties, reviewUuid) => void telemetry.captureEvent(event, properties, reviewUuid ? { reviewUuid } : void 0);
|
|
1024
|
+
return {
|
|
1025
|
+
onReviewCreated: ({ reviewId, kind, blocks, via, agentKind }) => {
|
|
1026
|
+
if (reported.has(reviewId)) return;
|
|
1027
|
+
reported.add(reviewId);
|
|
1028
|
+
if (via === "api" || via === "mcp") authoring.set(reviewId, agentKind);
|
|
1029
|
+
const properties = {
|
|
1030
|
+
kind,
|
|
1031
|
+
blocks,
|
|
1032
|
+
via
|
|
1033
|
+
};
|
|
1034
|
+
if (agentKind) properties.agent_kind = agentKind;
|
|
1035
|
+
capture("review_review_created", properties, reviewId);
|
|
1036
|
+
},
|
|
1037
|
+
sharing: {
|
|
1038
|
+
onPublished: ({ reviewId, version }) => {
|
|
1039
|
+
capture("review_review_published", { version }, reviewId);
|
|
1040
|
+
if (!authoring.has(reviewId)) return;
|
|
1041
|
+
const agentKind = authoring.get(reviewId);
|
|
1042
|
+
authoring.delete(reviewId);
|
|
1043
|
+
const createdAt = Date.parse(firstCreatedAt(reviewId) ?? "");
|
|
1044
|
+
if (Number.isNaN(createdAt)) return;
|
|
1045
|
+
const properties = { duration_ms: Math.max(0, now() - createdAt) };
|
|
1046
|
+
if (agentKind) properties.agent_kind = agentKind;
|
|
1047
|
+
capture("review_authoring_completed", properties, reviewId);
|
|
1048
|
+
},
|
|
1049
|
+
onRevoked: () => capture("review_review_revoked"),
|
|
1050
|
+
onLogin: (outcome, reason) => {
|
|
1051
|
+
capture(`review_login_${outcome}`, reason ? { reason } : {});
|
|
1052
|
+
if (outcome === "succeeded") onLoggedIn();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
//#endregion
|
|
1058
|
+
//#region src/server/review-open-watchdog.ts
|
|
1059
|
+
const OPEN_TIMEOUT_MS = 3e4;
|
|
1060
|
+
/**
|
|
1061
|
+
* "Opened but never presented" as an explicit event: one timer per
|
|
1062
|
+
* presentation from its session start, cleared when the canvas reports it
|
|
1063
|
+
* presented or the session ends. Keyed by presentation because a resume starts
|
|
1064
|
+
* a new session for the same review.
|
|
1065
|
+
*/
|
|
1066
|
+
var ReviewOpenWatchdog = class {
|
|
1067
|
+
options;
|
|
1068
|
+
pending = /* @__PURE__ */ new Map();
|
|
1069
|
+
constructor(options) {
|
|
1070
|
+
this.options = options;
|
|
1071
|
+
}
|
|
1072
|
+
started(context) {
|
|
1073
|
+
const id = context.presentationSessionId;
|
|
1074
|
+
this.presented(id);
|
|
1075
|
+
const startedAt = Date.now();
|
|
1076
|
+
const timer = setTimeout(() => {
|
|
1077
|
+
this.pending.delete(id);
|
|
1078
|
+
this.options.onTimeout(context, Date.now() - startedAt);
|
|
1079
|
+
}, OPEN_TIMEOUT_MS);
|
|
1080
|
+
timer.unref?.();
|
|
1081
|
+
this.pending.set(id, timer);
|
|
1082
|
+
}
|
|
1083
|
+
presented(presentationSessionId) {
|
|
1084
|
+
clearTimeout(this.pending.get(presentationSessionId));
|
|
1085
|
+
this.pending.delete(presentationSessionId);
|
|
1086
|
+
}
|
|
1087
|
+
dispose() {
|
|
1088
|
+
for (const timer of this.pending.values()) clearTimeout(timer);
|
|
1089
|
+
this.pending.clear();
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/server/tutorial-service.ts
|
|
1094
|
+
const authoredSchema = z.strictObject({
|
|
1095
|
+
title: z.string().min(1),
|
|
1096
|
+
document: documentSchema
|
|
1097
|
+
});
|
|
1098
|
+
const pinsSchema = z.strictObject({
|
|
1099
|
+
base: z.string().regex(/^[a-f0-9]{40}$/),
|
|
1100
|
+
head: z.string().regex(/^[a-f0-9]{40}$/)
|
|
1101
|
+
});
|
|
1102
|
+
const stampSchema = z.object({
|
|
1103
|
+
version: z.literal(10),
|
|
1104
|
+
reviewUuid: z.string(),
|
|
1105
|
+
contentHash: z.string()
|
|
1106
|
+
});
|
|
1107
|
+
async function readTutorialAssets(assetsRoot) {
|
|
1108
|
+
const bytes = await Promise.all([
|
|
1109
|
+
"document.json",
|
|
1110
|
+
"trace.json",
|
|
1111
|
+
"software-map.json",
|
|
1112
|
+
"pins.json"
|
|
1113
|
+
].map((name) => readFile(path.join(assetsRoot, name), "utf8")));
|
|
1114
|
+
return {
|
|
1115
|
+
authored: authoredSchema.parse(JSON.parse(bytes[0])),
|
|
1116
|
+
trace: JSON.parse(bytes[1]),
|
|
1117
|
+
model: JSON.parse(bytes[2]),
|
|
1118
|
+
pins: pinsSchema.parse(JSON.parse(bytes[3])),
|
|
1119
|
+
contentHash: createHash("sha256").update(JSON.stringify(bytes)).digest("hex")
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/** Create the shipped document through the same strict validation as authored reviews. */
|
|
1123
|
+
async function createNativeTutorial(input) {
|
|
1124
|
+
const assets = await readTutorialAssets(input.assetsRoot);
|
|
1125
|
+
const repository = await input.data.register(input.sampleRoot);
|
|
1126
|
+
const pins = await input.data.resolvePins(repository.id, assets.pins.base, assets.pins.head);
|
|
1127
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1128
|
+
const traceId = randomUUID();
|
|
1129
|
+
await input.data.upload({
|
|
1130
|
+
id: traceId,
|
|
1131
|
+
repositoryId: repository.id,
|
|
1132
|
+
kind: "trace",
|
|
1133
|
+
trace: assets.trace
|
|
1134
|
+
});
|
|
1135
|
+
aliases.set("tutorial-trace", traceId);
|
|
1136
|
+
for (const side of ["base", "head"]) {
|
|
1137
|
+
const id = randomUUID();
|
|
1138
|
+
await input.data.upload({
|
|
1139
|
+
id,
|
|
1140
|
+
repositoryId: repository.id,
|
|
1141
|
+
kind: "map",
|
|
1142
|
+
pins,
|
|
1143
|
+
side,
|
|
1144
|
+
model: assets.model
|
|
1145
|
+
});
|
|
1146
|
+
aliases.set(`tutorial-map-${side}`, id);
|
|
1147
|
+
}
|
|
1148
|
+
for (const block of resourceReferences(assets.authored.document)) {
|
|
1149
|
+
if (block.type === "trace_quote") block.traceId = aliases.get(block.traceId) ?? block.traceId;
|
|
1150
|
+
if (block.type === "software_map") block.mapVersionId = aliases.get(block.mapVersionId) ?? block.mapVersionId;
|
|
1151
|
+
}
|
|
1152
|
+
const result = await input.store.execute({ operation: {
|
|
1153
|
+
type: "create",
|
|
1154
|
+
title: assets.authored.title,
|
|
1155
|
+
target: {
|
|
1156
|
+
kind: "commits",
|
|
1157
|
+
...pins
|
|
1158
|
+
}
|
|
1159
|
+
} }, {
|
|
1160
|
+
document: assets.authored.document,
|
|
1161
|
+
origin: { tutorial: true }
|
|
1162
|
+
});
|
|
1163
|
+
return {
|
|
1164
|
+
snapshot: input.store.read(result.reviewId),
|
|
1165
|
+
contentHash: assets.contentHash
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function createTutorialService(input) {
|
|
1169
|
+
const tutorialRoot = path.join(devReviewHome(), "tutorial");
|
|
1170
|
+
const sampleRoot = path.join(tutorialRoot, "sample-service");
|
|
1171
|
+
const stampPath = path.join(tutorialRoot, "stamp.json");
|
|
1172
|
+
const assetsRoot = path.join(input.packageRoot, "tutorial");
|
|
1173
|
+
const readStamp = async () => stampSchema.safeParse(await readFile(stampPath, "utf8").then((value) => JSON.parse(value)).catch(() => null)).data;
|
|
1174
|
+
async function find() {
|
|
1175
|
+
try {
|
|
1176
|
+
const stamp = await readStamp();
|
|
1177
|
+
if (!stamp || !input.store.has(stamp.reviewUuid)) return null;
|
|
1178
|
+
const snapshot = input.store.read(stamp.reviewUuid);
|
|
1179
|
+
const assets = await readTutorialAssets(assetsRoot);
|
|
1180
|
+
const [head, base] = await Promise.all([resolveRevision(sampleRoot, "HEAD"), resolveRevision(sampleRoot, "HEAD^")]);
|
|
1181
|
+
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;
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async function cleanup() {
|
|
1187
|
+
for (const reviewId of input.store.tutorialIds()) await input.store.execute({ operation: {
|
|
1188
|
+
type: "delete",
|
|
1189
|
+
reviewId
|
|
1190
|
+
} });
|
|
1191
|
+
for (const repository of input.store.repositories()) {
|
|
1192
|
+
const relative = path.relative(tutorialRoot, repository.path);
|
|
1193
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) await input.data.forgetRepository(repository.id);
|
|
1194
|
+
}
|
|
1195
|
+
await rm(tutorialRoot, {
|
|
1196
|
+
recursive: true,
|
|
1197
|
+
force: true,
|
|
1198
|
+
maxRetries: 10
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
return {
|
|
1202
|
+
async status() {
|
|
1203
|
+
return {
|
|
1204
|
+
version: 1,
|
|
1205
|
+
reviewUuid: (await find())?.reviewId ?? null
|
|
1206
|
+
};
|
|
1207
|
+
},
|
|
1208
|
+
async referencesReview(id) {
|
|
1209
|
+
return input.store.has(id) && input.store.read(id).origin?.tutorial === true;
|
|
1210
|
+
},
|
|
1211
|
+
async prepare(options) {
|
|
1212
|
+
const current = await find();
|
|
1213
|
+
if (current) return current;
|
|
1214
|
+
await readTutorialAssets(assetsRoot);
|
|
1215
|
+
await options?.beforeReset();
|
|
1216
|
+
await cleanup();
|
|
1217
|
+
await mkdir(tutorialRoot, {
|
|
1218
|
+
recursive: true,
|
|
1219
|
+
mode: 448
|
|
1220
|
+
});
|
|
1221
|
+
const temporaryRoot = path.join(tutorialRoot, `.sample-service-${randomUUID()}`);
|
|
1222
|
+
try {
|
|
1223
|
+
await cp(path.join(assetsRoot, "sample-service"), temporaryRoot, { recursive: true });
|
|
1224
|
+
await cp(path.join(assetsRoot, "git-stub"), path.join(temporaryRoot, ".git"), { recursive: true });
|
|
1225
|
+
await rename(temporaryRoot, sampleRoot);
|
|
1226
|
+
const { snapshot, contentHash } = await createNativeTutorial({
|
|
1227
|
+
assetsRoot,
|
|
1228
|
+
sampleRoot,
|
|
1229
|
+
store: input.store,
|
|
1230
|
+
data: input.data
|
|
1231
|
+
});
|
|
1232
|
+
await writePrivateJsonAtomic(stampPath, {
|
|
1233
|
+
version: 10,
|
|
1234
|
+
reviewUuid: snapshot.reviewId,
|
|
1235
|
+
contentHash
|
|
1236
|
+
});
|
|
1237
|
+
return snapshot;
|
|
1238
|
+
} finally {
|
|
1239
|
+
await rm(temporaryRoot, {
|
|
1240
|
+
recursive: true,
|
|
1241
|
+
force: true
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
},
|
|
1245
|
+
cleanup
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/server/desktop-server.ts
|
|
1250
|
+
const TUTORIAL_LIFECYCLE_LOCK_KEY = "tutorial-lifecycle";
|
|
1251
|
+
function createGlobalReviewServer(input) {
|
|
1252
|
+
const instanceId = input.instanceId ?? crypto.randomUUID();
|
|
1253
|
+
const token = input.token ?? crypto.randomBytes(32).toString("base64url");
|
|
1254
|
+
let boundPort = input.port;
|
|
1255
|
+
const urlForBoundPort = () => `http://127.0.0.1:${boundPort}`;
|
|
1256
|
+
const identity = input.identity ?? {
|
|
1257
|
+
key: "stable",
|
|
1258
|
+
channel: "stable"
|
|
1259
|
+
};
|
|
1260
|
+
const discoveryPaths = input.discoveryPath ? [input.discoveryPath] : identity.key === "stable" ? [reviewInstanceDiscoveryPath("stable"), reviewLegacyDiscoveryPath()] : [reviewInstanceDiscoveryPath(identity.key)];
|
|
1261
|
+
const telemetry = input.telemetry ?? ReviewTelemetry.fromEnv();
|
|
1262
|
+
const relay = input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
|
|
1263
|
+
const askCliEnv = () => [
|
|
1264
|
+
{
|
|
1265
|
+
name: REVIEW_INSTANCE_ENV,
|
|
1266
|
+
value: identity.key
|
|
1267
|
+
},
|
|
1268
|
+
...process.versions.electron ? [{
|
|
1269
|
+
name: "ELECTRON_RUN_AS_NODE",
|
|
1270
|
+
value: "1"
|
|
1271
|
+
}] : [],
|
|
1272
|
+
...process.env.DEV_REVIEW_HOME ? [{
|
|
1273
|
+
name: "DEV_REVIEW_HOME",
|
|
1274
|
+
value: process.env.DEV_REVIEW_HOME
|
|
1275
|
+
}] : []
|
|
1276
|
+
];
|
|
1277
|
+
const askTools = {
|
|
1278
|
+
mcpServers: () => discovery.cliPath ? [{
|
|
1279
|
+
name: "whiteboard",
|
|
1280
|
+
command: process.execPath,
|
|
1281
|
+
args: [discovery.cliPath, "mcp"],
|
|
1282
|
+
env: askCliEnv()
|
|
1283
|
+
}] : [],
|
|
1284
|
+
cli: () => discovery.cliPath && [
|
|
1285
|
+
...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
|
|
1286
|
+
shellQuote(process.execPath),
|
|
1287
|
+
shellQuote(discovery.cliPath)
|
|
1288
|
+
].join(" ")
|
|
1289
|
+
};
|
|
1290
|
+
const reviewStore = input.reviewStore;
|
|
1291
|
+
const reviewLocks = /* @__PURE__ */ new Map();
|
|
1292
|
+
let appReadyReported = false;
|
|
1293
|
+
const openWatchdog = new ReviewOpenWatchdog({ onTimeout: (context, elapsedMs) => void telemetry.captureEvent("review_open_timeout", { elapsed_ms: elapsedMs }, context) });
|
|
1294
|
+
const tutorial = createTutorialService({
|
|
1295
|
+
packageRoot: input.packageRoot,
|
|
1296
|
+
store: reviewStore,
|
|
1297
|
+
data: input.reviewData
|
|
1298
|
+
});
|
|
1299
|
+
let closing = false;
|
|
1300
|
+
const cliPath = path.join(input.packageRoot, "dist", "cli.js");
|
|
1301
|
+
let scratchpadEnabled = false;
|
|
1302
|
+
const discovery = {
|
|
1303
|
+
version: 3,
|
|
1304
|
+
instanceId,
|
|
1305
|
+
url: urlForBoundPort(),
|
|
1306
|
+
appPid: input.appPid,
|
|
1307
|
+
serverPid: process.pid,
|
|
1308
|
+
token,
|
|
1309
|
+
startedAt: Date.now(),
|
|
1310
|
+
...identity
|
|
1311
|
+
};
|
|
1312
|
+
if (existsSync(cliPath)) {
|
|
1313
|
+
discovery.cliPath = cliPath;
|
|
1314
|
+
discovery.cliVersion = readReviewPackageVersion(pathToFileURL(cliPath).href);
|
|
1315
|
+
if (input.cliRuntimePath && existsSync(input.cliRuntimePath)) discovery.cliRuntimePath = input.cliRuntimePath;
|
|
1316
|
+
}
|
|
1317
|
+
const core = createWhiteboardCore({
|
|
1318
|
+
profile: {
|
|
1319
|
+
store: reviewStore,
|
|
1320
|
+
data: input.reviewData,
|
|
1321
|
+
shared: input.sharedReviews
|
|
1322
|
+
},
|
|
1323
|
+
relay,
|
|
1324
|
+
token,
|
|
1325
|
+
instanceId,
|
|
1326
|
+
scratchpad: () => scratchpadEnabled,
|
|
1327
|
+
status: () => {
|
|
1328
|
+
const { key, channel, checkout, appVersion, cliVersion, instanceId } = discovery;
|
|
1329
|
+
return {
|
|
1330
|
+
key: key ?? null,
|
|
1331
|
+
channel: channel ?? null,
|
|
1332
|
+
checkout: checkout ?? null,
|
|
1333
|
+
appVersion: appVersion ?? null,
|
|
1334
|
+
cliVersion: cliVersion ?? null,
|
|
1335
|
+
instanceId,
|
|
1336
|
+
url: urlForBoundPort(),
|
|
1337
|
+
home: devReviewHome()
|
|
1338
|
+
};
|
|
1339
|
+
},
|
|
1340
|
+
hooks: reviewLifecycleTelemetry(telemetry, (reviewId) => reviewStore.summary(reviewId)?.firstCreatedAt, () => aliasInstallationToAccount(telemetry)),
|
|
1341
|
+
ask: { tools: askTools }
|
|
1342
|
+
});
|
|
1343
|
+
const { app } = core;
|
|
1344
|
+
app.route("/reviews-api", createJsonReviewReporting(input.reviewStore, telemetry, { shared: input.sharedReviews }));
|
|
1345
|
+
app.route("/reviews-api", core.api);
|
|
1346
|
+
app.get("/preferences/scratchpad", () => serverJson(200, { enabled: scratchpadEnabled }));
|
|
1347
|
+
app.put("/preferences/scratchpad", async (context) => {
|
|
1348
|
+
const request = z.object({ enabled: z.boolean() }).safeParse(await readBoundedRequestJson(context.req.raw));
|
|
1349
|
+
if (!request.success) throw new ReviewServerError("enabled must be a boolean.", 400);
|
|
1350
|
+
scratchpadEnabled = await writeScratchpadEnabled(request.data.enabled);
|
|
1351
|
+
if (scratchpadEnabled) await reviewStore.ensureScratchpad();
|
|
1352
|
+
reviewStore.invalidateCatalog();
|
|
1353
|
+
return serverJson(200, { enabled: scratchpadEnabled });
|
|
1354
|
+
});
|
|
1355
|
+
app.post("/app/focus", async () => {
|
|
1356
|
+
const result = await relay.dispatch({
|
|
1357
|
+
name: "focusWindow",
|
|
1358
|
+
args: {}
|
|
1359
|
+
});
|
|
1360
|
+
return serverJson(result.ok ? 200 : 409, result);
|
|
1361
|
+
});
|
|
1362
|
+
app.post("/telemetry/event", async (context) => {
|
|
1363
|
+
try {
|
|
1364
|
+
const body = await readBoundedRequestJson(context.req.raw, void 0, {});
|
|
1365
|
+
const payload = isJsonObject(body) ? body : {};
|
|
1366
|
+
let flushBeforeOptOut = false;
|
|
1367
|
+
if (payload.name === "app_ready") {
|
|
1368
|
+
if (appReadyReported) return serverJson(200, { ok: true });
|
|
1369
|
+
appReadyReported = true;
|
|
1370
|
+
}
|
|
1371
|
+
const eventProperties = isJsonObject(payload.properties) ? { ...payload.properties } : {};
|
|
1372
|
+
if (payload.name === "session_started") {
|
|
1373
|
+
delete eventProperties.source_kind;
|
|
1374
|
+
delete eventProperties.agent_kind;
|
|
1375
|
+
const rawContext = isJsonObject(payload.context) ? payload.context : {};
|
|
1376
|
+
const sourceKind = sessionStartedSourceKind(reviewStore, jsonString(rawContext.reviewUuid));
|
|
1377
|
+
if (sourceKind) eventProperties.source_kind = sourceKind;
|
|
1378
|
+
}
|
|
1379
|
+
await captureSanitizedUiTelemetry(telemetry, context.req.raw, payload.name, eventProperties, (event, eventContext) => {
|
|
1380
|
+
flushBeforeOptOut = event.event === "review_setting_changed" && event.properties.setting === "telemetry_enabled" && event.properties.enabled === false;
|
|
1381
|
+
watchSessionOpen(openWatchdog, event.event, eventContext);
|
|
1382
|
+
}, payload.error, payload.context, payload.occurredAt);
|
|
1383
|
+
if (flushBeforeOptOut) await telemetry.flush(500);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
console.error(error);
|
|
1386
|
+
}
|
|
1387
|
+
return serverJson(200, { ok: true });
|
|
1388
|
+
});
|
|
1389
|
+
app.post("/crash-reports", async (context) => {
|
|
1390
|
+
const body = CrashReportRequestSchema.safeParse(await readBoundedRequestJson(context.req.raw));
|
|
1391
|
+
if (!body.success) throw new ReviewServerError("dump_path, crashed_at and covered are required.", 400);
|
|
1392
|
+
const result = await reportCrashDump(telemetry, body.data, input.crashDumpsDir);
|
|
1393
|
+
return serverJson(result.status, result.body);
|
|
1394
|
+
});
|
|
1395
|
+
app.get("/tutorial/status", async () => serverJson(200, await tutorial.status()));
|
|
1396
|
+
app.post("/tutorial/prepare", async () => {
|
|
1397
|
+
const prepared = await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, prepareTutorialLocked);
|
|
1398
|
+
return serverJson(200, {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
reviewUuid: prepared.reviewId
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
app.post("/tutorial/open", async () => {
|
|
1404
|
+
return serverJson(200, await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, openTutorialLocked));
|
|
1405
|
+
});
|
|
1406
|
+
app.delete("/tutorial", async () => {
|
|
1407
|
+
await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, deleteTutorialLocked);
|
|
1408
|
+
return serverJson(200, { ok: true });
|
|
1409
|
+
});
|
|
1410
|
+
app.post("/diffr-languages/install", async () => {
|
|
1411
|
+
try {
|
|
1412
|
+
await installFullDiffr();
|
|
1413
|
+
invalidateStructuralComparisons();
|
|
1414
|
+
return serverJson(200, { installed: true });
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
return serverJson(503, { error: error instanceof Error ? error.message : String(error) });
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
app.get("/diffr-config", async () => serverJson(200, await readDiffrConfig()));
|
|
1420
|
+
app.put("/diffr-config", async (context) => {
|
|
1421
|
+
const body = await readBoundedRequestJson(context.req.raw);
|
|
1422
|
+
const key = isJsonObject(body) ? jsonString(body.key) : void 0;
|
|
1423
|
+
const value = isJsonObject(body) ? body.value : void 0;
|
|
1424
|
+
if (key === void 0 || value === void 0) throw new ReviewServerError("key and value are required.", 400);
|
|
1425
|
+
return serverJson(200, await setDiffrConfigValue(key, value));
|
|
1426
|
+
});
|
|
1427
|
+
app.put("/diffr-config/summarizer", async (context) => {
|
|
1428
|
+
const input = reviewDiffrSummarizerInputSchema.safeParse(await readBoundedRequestJson(context.req.raw));
|
|
1429
|
+
if (!input.success) throw new ReviewServerError("Invalid summary settings.", 400);
|
|
1430
|
+
return serverJson(200, await saveDiffrSummarizer(input.data));
|
|
1431
|
+
});
|
|
1432
|
+
app.post("/diffr-config/summarizer/test", async (context) => {
|
|
1433
|
+
const input = reviewDiffrSummarizerInputSchema.safeParse(await readBoundedRequestJson(context.req.raw));
|
|
1434
|
+
if (!input.success) throw new ReviewServerError("Invalid summary settings.", 400);
|
|
1435
|
+
return serverJson(200, { summary: await testDiffrSummarizer(input.data, void 0, context.req.raw.signal) });
|
|
1436
|
+
});
|
|
1437
|
+
app.get("/install/status", async () => serverJson(200, await resolveCliInstallStatus({ packageRoot: input.packageRoot })));
|
|
1438
|
+
app.post("/install/apply", async (context) => {
|
|
1439
|
+
const request = parseReviewCliInstallApplyRequest(await readBoundedRequestJson(context.req.raw));
|
|
1440
|
+
const applyInput = { packageRoot: input.packageRoot };
|
|
1441
|
+
if (request.shim !== void 0) applyInput.shim = request.shim;
|
|
1442
|
+
if (request.autoUpdate) applyInput.autoUpdate = true;
|
|
1443
|
+
if (request.trace !== void 0) applyInput.trace = request.trace;
|
|
1444
|
+
if (discovery.cliPath) applyInput.cliPath = discovery.cliPath;
|
|
1445
|
+
if (discovery.cliRuntimePath) applyInput.cliRuntimePath = discovery.cliRuntimePath;
|
|
1446
|
+
const result = await applyCliInstall(applyInput);
|
|
1447
|
+
const body = {
|
|
1448
|
+
ok: result.code === 0,
|
|
1449
|
+
output: result.output
|
|
1450
|
+
};
|
|
1451
|
+
if (result.shimPath) body.shimPath = result.shimPath;
|
|
1452
|
+
return serverJson(result.code === 0 ? 200 : 500, body);
|
|
1453
|
+
});
|
|
1454
|
+
app.post("/install/remove", async (context) => {
|
|
1455
|
+
const request = parseReviewCliInstallApplyRequest(await readBoundedRequestJson(context.req.raw));
|
|
1456
|
+
const removeInput = {};
|
|
1457
|
+
if (request.shim) removeInput.shim = true;
|
|
1458
|
+
if (request.trace) removeInput.trace = true;
|
|
1459
|
+
const result = await removeCliInstall(removeInput);
|
|
1460
|
+
return serverJson(200, {
|
|
1461
|
+
ok: true,
|
|
1462
|
+
output: result.output
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
app.post("/install/legacy-skills/remove", async () => {
|
|
1466
|
+
const { removed } = await removeLegacyReviewSkills();
|
|
1467
|
+
return serverJson(200, {
|
|
1468
|
+
ok: true,
|
|
1469
|
+
removed
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
app.post("/install/finish-update", async () => {
|
|
1473
|
+
await finishCliInstallUpdate();
|
|
1474
|
+
return serverJson(200, { ok: true });
|
|
1475
|
+
});
|
|
1476
|
+
app.post("/install/decline", async () => {
|
|
1477
|
+
await declineCliInstall();
|
|
1478
|
+
return serverJson(200, { ok: true });
|
|
1479
|
+
});
|
|
1480
|
+
app.post("/install/skip", async () => {
|
|
1481
|
+
await skipCliInstall();
|
|
1482
|
+
return serverJson(200, { ok: true });
|
|
1483
|
+
});
|
|
1484
|
+
app.post("/install/reset", async () => {
|
|
1485
|
+
await resetCliInstall();
|
|
1486
|
+
return serverJson(200, { ok: true });
|
|
1487
|
+
});
|
|
1488
|
+
const httpServer = createServer(createNodeRequestListener(app));
|
|
1489
|
+
async function prepareTutorialLocked() {
|
|
1490
|
+
return tutorial.prepare();
|
|
1491
|
+
}
|
|
1492
|
+
async function openTutorialLocked() {
|
|
1493
|
+
const snapshot = await prepareTutorialLocked();
|
|
1494
|
+
return {
|
|
1495
|
+
kind: "api",
|
|
1496
|
+
reviewUuid: snapshot.reviewId,
|
|
1497
|
+
title: snapshot.title
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
async function deleteTutorialLocked() {
|
|
1501
|
+
await tutorial.cleanup();
|
|
1502
|
+
}
|
|
1503
|
+
async function withReviewLock(reviewUuid, operation) {
|
|
1504
|
+
const previous = reviewLocks.get(reviewUuid) ?? Promise.resolve();
|
|
1505
|
+
let release = () => {};
|
|
1506
|
+
const current = new Promise((resolve) => {
|
|
1507
|
+
release = resolve;
|
|
1508
|
+
});
|
|
1509
|
+
const chain = previous.then(() => current);
|
|
1510
|
+
reviewLocks.set(reviewUuid, chain);
|
|
1511
|
+
await previous;
|
|
1512
|
+
try {
|
|
1513
|
+
return await operation();
|
|
1514
|
+
} finally {
|
|
1515
|
+
release();
|
|
1516
|
+
if (reviewLocks.get(reviewUuid) === chain) reviewLocks.delete(reviewUuid);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
discovery,
|
|
1521
|
+
get url() {
|
|
1522
|
+
return urlForBoundPort();
|
|
1523
|
+
},
|
|
1524
|
+
listen: async () => {
|
|
1525
|
+
scratchpadEnabled = await readScratchpadEnabled();
|
|
1526
|
+
boundPort = await listen(httpServer, input.port);
|
|
1527
|
+
discovery.url = urlForBoundPort();
|
|
1528
|
+
for (const discoveryPath of discoveryPaths) await writePrivateJsonAtomic(discoveryPath, discovery);
|
|
1529
|
+
},
|
|
1530
|
+
close: async () => {
|
|
1531
|
+
if (closing) return;
|
|
1532
|
+
closing = true;
|
|
1533
|
+
core.close();
|
|
1534
|
+
for (const discoveryPath of discoveryPaths) await removeMatchingDiscovery(discoveryPath, discovery);
|
|
1535
|
+
relay.close();
|
|
1536
|
+
openWatchdog.dispose();
|
|
1537
|
+
await closeHttpServer(httpServer);
|
|
1538
|
+
await telemetry.shutdown(1500);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
function watchSessionOpen(watchdog, event, context) {
|
|
1543
|
+
const reviewUuid = context?.reviewUuid;
|
|
1544
|
+
const presentationSessionId = context?.presentationSessionId;
|
|
1545
|
+
if (!reviewUuid || !presentationSessionId) return;
|
|
1546
|
+
if (event === "review_session_started") watchdog.started({
|
|
1547
|
+
reviewUuid,
|
|
1548
|
+
presentationSessionId
|
|
1549
|
+
});
|
|
1550
|
+
else if (event === "review_review_presented" || event === "review_session_ended") watchdog.presented(presentationSessionId);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* `session_started`'s `source_kind`: the opened review's target kind, or
|
|
1554
|
+
* `scratchpad` for the one scratchpad. Undefined when the review is gone (a
|
|
1555
|
+
* shared review this store never had, or one deleted between open and the
|
|
1556
|
+
* event arriving) or was left without a target.
|
|
1557
|
+
*/
|
|
1558
|
+
function sessionStartedSourceKind(reviewStore, reviewUuid) {
|
|
1559
|
+
if (!reviewUuid) return void 0;
|
|
1560
|
+
try {
|
|
1561
|
+
const snapshot = reviewStore.read(reviewUuid);
|
|
1562
|
+
return snapshot.kind === "scratchpad" ? snapshot.kind : snapshot.target?.kind;
|
|
1563
|
+
} catch {
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
function listen(server, port) {
|
|
1568
|
+
return new Promise((resolve, reject) => {
|
|
1569
|
+
server.once("error", reject);
|
|
1570
|
+
server.listen(port, "127.0.0.1", () => {
|
|
1571
|
+
server.off("error", reject);
|
|
1572
|
+
const address = server.address();
|
|
1573
|
+
if (!isTcpAddress(address)) {
|
|
1574
|
+
reject(/* @__PURE__ */ new Error("The Whiteboard server did not bind a TCP port."));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
resolve(address.port);
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
function closeHttpServer(server) {
|
|
1582
|
+
return new Promise((resolve, reject) => {
|
|
1583
|
+
if (!server.listening) {
|
|
1584
|
+
resolve();
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
async function removeMatchingDiscovery(filePath, discovery) {
|
|
1591
|
+
try {
|
|
1592
|
+
const current = JSON.parse(await readFile(filePath, "utf8"));
|
|
1593
|
+
if (isJsonObject(current) && current.instanceId === discovery.instanceId && current.appPid === discovery.appPid) await rm(filePath, { force: true });
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
if (error.code !== "ENOENT") throw error;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/** `server.address()` is a string for pipe and socket listeners. */
|
|
1599
|
+
function isTcpAddress(address) {
|
|
1600
|
+
return isObjectValue(address);
|
|
1601
|
+
}
|
|
1602
|
+
//#endregion
|
|
1603
|
+
//#region src/server/desktop-host.ts
|
|
1604
|
+
async function runDesktopHost(env = process.env) {
|
|
1605
|
+
const port = requiredPort(env.DEV_FAST_REVIEW_SERVER_PORT);
|
|
1606
|
+
const appPid = requiredPositiveInteger(env.DEV_FAST_REVIEW_APP_PID, "DEV_FAST_REVIEW_APP_PID");
|
|
1607
|
+
const packageRoot = findReviewPackageRoot(import.meta.url);
|
|
1608
|
+
const toolingRoot = env.DEV_FAST_REVIEW_TOOLING_ROOT || packageRoot;
|
|
1609
|
+
const telemetryEnv = { ...env };
|
|
1610
|
+
delete telemetryEnv.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
|
|
1611
|
+
const telemetry = ReviewTelemetry.fromEnv(telemetryEnv, { surface: "desktop" });
|
|
1612
|
+
installProcessErrorTelemetry(telemetry, {
|
|
1613
|
+
appSessionId: env.DEV_FAST_REVIEW_APP_SESSION_ID,
|
|
1614
|
+
crashReportPath: serverCrashReportPath(env)
|
|
1615
|
+
});
|
|
1616
|
+
await telemetry.setEnabled(!isEnabledEnvValue(env.DEV_FAST_REVIEW_TELEMETRY_DISABLED));
|
|
1617
|
+
if (reviewTelemetryChannel(env) === "dev") await telemetry.setInternal(true);
|
|
1618
|
+
await telemetry.captureInstallationCreated().catch(() => void 0);
|
|
1619
|
+
await telemetry.reconcileOpenSessions().catch(() => void 0);
|
|
1620
|
+
await drainServerCrashReport(telemetry, serverCrashReportPath(env));
|
|
1621
|
+
const installationId = await telemetry.getInstallationId();
|
|
1622
|
+
delete env.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
|
|
1623
|
+
const serverInput = {
|
|
1624
|
+
appPid,
|
|
1625
|
+
packageRoot,
|
|
1626
|
+
toolingRoot,
|
|
1627
|
+
port,
|
|
1628
|
+
token: env.DEV_FAST_REVIEW_SERVER_TOKEN,
|
|
1629
|
+
instanceId: env.DEV_FAST_REVIEW_INSTANCE_ID,
|
|
1630
|
+
identity: reviewInstanceIdentity(env),
|
|
1631
|
+
telemetry
|
|
1632
|
+
};
|
|
1633
|
+
const home = devReviewHome(env);
|
|
1634
|
+
await mkdir(home, { recursive: true });
|
|
1635
|
+
const local = await openReviewProfile(home, { manageWorkspaces: true });
|
|
1636
|
+
const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
|
|
1637
|
+
shared.connect(local.store, local.data);
|
|
1638
|
+
await shared.load();
|
|
1639
|
+
const server = createGlobalReviewServer({
|
|
1640
|
+
...serverInput,
|
|
1641
|
+
sharedReviews: shared,
|
|
1642
|
+
reviewStore: local.store,
|
|
1643
|
+
reviewData: local.data,
|
|
1644
|
+
cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
|
|
1645
|
+
crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR
|
|
1646
|
+
});
|
|
1647
|
+
try {
|
|
1648
|
+
await server.listen();
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
await local?.data.close();
|
|
1651
|
+
await local?.store.close();
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
process.stdout.write(`${JSON.stringify({
|
|
1655
|
+
event: "ready",
|
|
1656
|
+
...server.discovery,
|
|
1657
|
+
installationId
|
|
1658
|
+
})}\n`);
|
|
1659
|
+
const stageRustAnalyzer = () => ensureBundledRustAnalyzer({ env }).catch((error) => {
|
|
1660
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1661
|
+
process.stderr.write(`[Review tools] Could not stage bundled rust-analyzer: ${reason}\n`);
|
|
1662
|
+
});
|
|
1663
|
+
stageRustAnalyzer();
|
|
1664
|
+
let stopping = null;
|
|
1665
|
+
const stop = () => {
|
|
1666
|
+
if (!stopping) stopping = server.close("app-exit").finally(() => shared.close()).finally(() => local?.data.close()).finally(() => local?.store.close());
|
|
1667
|
+
return stopping;
|
|
1668
|
+
};
|
|
1669
|
+
listenForDesktopHostShutdown(process, () => {
|
|
1670
|
+
stop().then(() => process.exit(0));
|
|
1671
|
+
}, (enabled) => {
|
|
1672
|
+
telemetry.setEnabled(enabled).catch(() => void 0);
|
|
1673
|
+
}, (sourcePath) => {
|
|
1674
|
+
env.DEV_FAST_REVIEW_RUST_ANALYZER = sourcePath;
|
|
1675
|
+
stageRustAnalyzer();
|
|
1676
|
+
});
|
|
1677
|
+
process.once("SIGINT", () => {
|
|
1678
|
+
stop().then(() => process.exit(0));
|
|
1679
|
+
});
|
|
1680
|
+
process.once("SIGTERM", () => {
|
|
1681
|
+
stop().then(() => process.exit(0));
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
function isEnabledEnvValue(value) {
|
|
1685
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
1686
|
+
}
|
|
1687
|
+
function requiredPort(value) {
|
|
1688
|
+
const parsed = Number(value);
|
|
1689
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("DEV_FAST_REVIEW_SERVER_PORT must be a port between 0 and 65535.");
|
|
1690
|
+
return parsed;
|
|
1691
|
+
}
|
|
1692
|
+
function requiredPositiveInteger(value, name) {
|
|
1693
|
+
const parsed = Number(value);
|
|
1694
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer.`);
|
|
1695
|
+
return parsed;
|
|
1696
|
+
}
|
|
1697
|
+
if (process.env.DEV_FAST_REVIEW_DESKTOP_HOST_AUTOSTART !== "0") runDesktopHost().catch((error) => {
|
|
1698
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
1699
|
+
process.exitCode = 1;
|
|
1700
|
+
});
|
|
1701
|
+
//#endregion
|
|
1702
|
+
export { runDesktopHost };
|