@dev.fast/whiteboard 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,355 @@
1
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ type JsonValue,
6
+ REVIEW_DESKTOP_DISCOVERY_VERSION,
7
+ type ReviewDesktopDiscovery,
8
+ isJsonObject,
9
+ jsonNumber,
10
+ jsonProperty,
11
+ parseJsonText,
12
+ parseReviewDesktopDiscovery,
13
+ } from "@dev.fast/review-protocol";
14
+
15
+ import {
16
+ reviewDefaultInstancePath,
17
+ reviewDevInstanceKey,
18
+ reviewInstancesDir,
19
+ reviewLegacyDiscoveryPath,
20
+ } from "./review-home-paths";
21
+
22
+ export class ReviewDesktopProtocolMismatchError extends Error {
23
+ readonly name = "ReviewDesktopProtocolMismatchError";
24
+
25
+ constructor(
26
+ readonly actualVersion: number,
27
+ readonly expectedVersion = REVIEW_DESKTOP_DISCOVERY_VERSION,
28
+ ) {
29
+ super(
30
+ `Whiteboard Desktop uses protocol ${actualVersion}, but this Whiteboard CLI needs protocol ${expectedVersion}. Update the Whiteboard CLI and Whiteboard Desktop to compatible versions, then try again.`,
31
+ );
32
+ }
33
+ }
34
+
35
+ export class ReviewDesktopDiscoveryUnreadableError extends Error {
36
+ readonly name = "ReviewDesktopDiscoveryUnreadableError";
37
+
38
+ constructor(filePath: string, detail?: string) {
39
+ super(
40
+ `Whiteboard Desktop discovery is unreadable at ${filePath}. Restart Whiteboard Desktop and try again.${detail ? ` ${detail}` : ""}`,
41
+ );
42
+ }
43
+ }
44
+
45
+ /** One record, or null when the file does not exist. */
46
+ export async function readReviewDesktopDiscoveryFile(
47
+ filePath: string,
48
+ ): Promise<ReviewDesktopDiscovery | null> {
49
+ let source: string;
50
+
51
+ try {
52
+ source = await readFile(filePath, "utf8");
53
+ } catch (error) {
54
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
55
+ return null;
56
+ }
57
+
58
+ throw new ReviewDesktopDiscoveryUnreadableError(filePath, String(error));
59
+ }
60
+
61
+ let value: JsonValue;
62
+
63
+ try {
64
+ value = parseJsonText(source);
65
+ } catch (error) {
66
+ throw new ReviewDesktopDiscoveryUnreadableError(filePath, String(error));
67
+ }
68
+
69
+ try {
70
+ return parseReviewDesktopDiscovery(value);
71
+ } catch (error) {
72
+ const version = isJsonObject(value)
73
+ ? jsonNumber(jsonProperty(value, "version"))
74
+ : undefined;
75
+
76
+ if (
77
+ version !== undefined &&
78
+ Number.isInteger(version) &&
79
+ version !== REVIEW_DESKTOP_DISCOVERY_VERSION
80
+ ) {
81
+ throw new ReviewDesktopProtocolMismatchError(version);
82
+ }
83
+
84
+ throw new ReviewDesktopDiscoveryUnreadableError(filePath, String(error));
85
+ }
86
+ }
87
+
88
+ export async function isHealthyReviewDesktop(
89
+ discovery: ReviewDesktopDiscovery,
90
+ fetch = globalThis.fetch,
91
+ ): Promise<boolean> {
92
+ try {
93
+ const response = await fetch(`${discovery.url}/health`, {
94
+ signal: AbortSignal.timeout(1_500),
95
+ });
96
+
97
+ if (!response.ok) return false;
98
+ const health = await response.json();
99
+
100
+ return (
101
+ isJsonObject(health) &&
102
+ health.ok === true &&
103
+ health.instanceId === discovery.instanceId &&
104
+ health.desktopAttached === true
105
+ );
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** The selected Desktop's record while it is running, else null. */
112
+ export function healthyReviewInstance(
113
+ selection: ReviewInstanceSelection,
114
+ ): ReviewDesktopDiscovery | null {
115
+ return selection.instance?.healthy ? selection.instance.discovery : null;
116
+ }
117
+
118
+ export async function requireHealthyReviewDesktop(
119
+ fetch = globalThis.fetch,
120
+ ): Promise<ReviewDesktopDiscovery> {
121
+ const selection = await selectReviewInstance({ fetch });
122
+ const discovery = healthyReviewInstance(selection);
123
+
124
+ if (!discovery) throw reviewInstanceUnavailable(selection);
125
+
126
+ return discovery;
127
+ }
128
+
129
+ /** Shell-session override; agents inherit it through the bare shim. */
130
+ export const REVIEW_INSTANCE_ENV = "DEV_REVIEW_INSTANCE";
131
+
132
+ // Keys name files, so nothing else may select one.
133
+ function checkedInstanceKey(key: string, origin = "") {
134
+ if (/^(?:stable|preview|dev-[A-Za-z0-9_.-]+)$/.test(key)) return key;
135
+ throw new Error(
136
+ `Unknown Whiteboard instance ${JSON.stringify(key)}${origin}. Use stable, preview, or a dev-… key from \`whiteboard instances\`.`,
137
+ );
138
+ }
139
+
140
+ export type ReviewInstanceIdentity = Required<
141
+ Pick<ReviewDesktopDiscovery, "key" | "channel">
142
+ > &
143
+ Pick<ReviewDesktopDiscovery, "checkout" | "appPath" | "appVersion">;
144
+
145
+ /** Identity of the Desktop hosting this process, from the env electron-main passes. */
146
+ export function reviewInstanceIdentity(
147
+ env: NodeJS.ProcessEnv,
148
+ ): ReviewInstanceIdentity {
149
+ const checkout = env.DEV_FAST_REVIEW_CHECKOUT?.trim();
150
+
151
+ const channel = checkout
152
+ ? "dev"
153
+ : env.DEV_FAST_REVIEW_RELEASE_CHANNEL === "preview"
154
+ ? "preview"
155
+ : "stable";
156
+
157
+ const identity: ReviewInstanceIdentity = {
158
+ key: checkout ? reviewDevInstanceKey(checkout) : channel,
159
+ channel,
160
+ };
161
+
162
+ if (checkout) identity.checkout = path.resolve(checkout);
163
+ const appPath = env.DEV_FAST_REVIEW_APP_PATH?.trim();
164
+
165
+ if (appPath) identity.appPath = appPath;
166
+ const appVersion = env.DEV_FAST_REVIEW_APP_VERSION?.trim();
167
+
168
+ if (appVersion) identity.appVersion = appVersion;
169
+
170
+ return identity;
171
+ }
172
+
173
+ export interface ReviewInstance {
174
+ key: string;
175
+ filePath: string;
176
+ discovery: ReviewDesktopDiscovery;
177
+ healthy: boolean;
178
+ }
179
+
180
+ export interface ReviewInstanceDependencies {
181
+ env?: NodeJS.ProcessEnv;
182
+ fetch?: typeof globalThis.fetch;
183
+ warn?: (message: string) => void;
184
+ }
185
+
186
+ /** Records by key, plus the files that could not be read, by key. */
187
+ export async function readReviewInstances(
188
+ dependencies: ReviewInstanceDependencies,
189
+ ) {
190
+ const env = dependencies.env ?? process.env;
191
+ const directory = reviewInstancesDir(env);
192
+ const names = await readdir(directory).catch((): string[] => []);
193
+
194
+ const files = names
195
+ .filter((name) => name.endsWith(".json"))
196
+ .map((name): [string, string] => [
197
+ path.basename(name, ".json"),
198
+ path.join(directory, name),
199
+ ]);
200
+
201
+ // A stable Desktop that predates instances wrote only the legacy file; it
202
+ // stands in for stable only while no stable record exists at all.
203
+ if (!names.includes("stable.json"))
204
+ files.push(["stable", reviewLegacyDiscoveryPath(env)]);
205
+
206
+ const records: Omit<ReviewInstance, "healthy">[] = [];
207
+ const broken = new Map<string, Error>();
208
+
209
+ for (const [key, filePath] of files) {
210
+ try {
211
+ const discovery = await readReviewDesktopDiscoveryFile(filePath);
212
+
213
+ if (discovery) records.push({ key, filePath, discovery });
214
+ } catch (error) {
215
+ const problem = error instanceof Error ? error : new Error(String(error));
216
+ broken.set(key, problem);
217
+ dependencies.warn?.(`Skipping ${filePath}: ${problem.message}`);
218
+ }
219
+ }
220
+
221
+ const instances = await Promise.all(
222
+ records.map(async (record) => ({
223
+ ...record,
224
+ healthy: await isHealthyReviewDesktop(
225
+ record.discovery,
226
+ dependencies.fetch,
227
+ ),
228
+ })),
229
+ );
230
+
231
+ return { instances, broken };
232
+ }
233
+
234
+ export async function readDefaultReviewInstance(
235
+ env: NodeJS.ProcessEnv = process.env,
236
+ ): Promise<string | undefined> {
237
+ const value = await readFile(reviewDefaultInstancePath(env), "utf8")
238
+ .then((text) => text.trim())
239
+ .catch(() => "");
240
+
241
+ return value || undefined;
242
+ }
243
+
244
+ export async function writeDefaultReviewInstance(
245
+ key: string,
246
+ env: NodeJS.ProcessEnv = process.env,
247
+ ): Promise<void> {
248
+ const filePath = reviewDefaultInstancePath(env);
249
+ await mkdir(path.dirname(filePath), { recursive: true });
250
+ await writeFile(filePath, `${checkedInstanceKey(key)}\n`);
251
+ }
252
+
253
+ export async function clearDefaultReviewInstance(
254
+ env: NodeJS.ProcessEnv = process.env,
255
+ ): Promise<void> {
256
+ await rm(reviewDefaultInstancePath(env), { force: true });
257
+ }
258
+
259
+ export interface ReviewInstanceSelection {
260
+ key: string;
261
+ source: "env" | "default" | "only-running" | "fallback";
262
+ /** The selected key's record, when one exists. */
263
+ instance?: ReviewInstance;
264
+ instances: ReviewInstance[];
265
+ /** Why the selected key's record could not be read, when it has a broken one. */
266
+ problem?: Error;
267
+ }
268
+
269
+ /** DEV_REVIEW_INSTANCE, then the machine default, then the only running Desktop, then stable. */
270
+ export async function selectReviewInstance(
271
+ dependencies: ReviewInstanceDependencies = {},
272
+ ): Promise<ReviewInstanceSelection> {
273
+ const env = dependencies.env ?? process.env;
274
+ const { instances, broken } = await readReviewInstances(dependencies);
275
+ const fromEnv = env[REVIEW_INSTANCE_ENV]?.trim();
276
+
277
+ const fromDefault = fromEnv
278
+ ? undefined
279
+ : await readDefaultReviewInstance(env);
280
+
281
+ const running = instances.filter((instance) => instance.healthy);
282
+
283
+ const [key, source]: [string, ReviewInstanceSelection["source"]] = fromEnv
284
+ ? [checkedInstanceKey(fromEnv, ` from ${REVIEW_INSTANCE_ENV}`), "env"]
285
+ : fromDefault
286
+ ? [
287
+ checkedInstanceKey(
288
+ fromDefault,
289
+ " as the machine default (`whiteboard instances clear` removes it)",
290
+ ),
291
+ "default",
292
+ ]
293
+ : running.length === 1
294
+ ? [running[0]!.key, "only-running"]
295
+ : ["stable", "fallback"];
296
+
297
+ const selection: ReviewInstanceSelection = { key, source, instances };
298
+ const instance = instances.find((instance) => instance.key === key);
299
+
300
+ // A broken record is a diagnosis, not "not running": `app pick` must not
301
+ // launch a second Desktop over it. With nothing selected explicitly, any
302
+ // broken record may be the one Desktop that is running.
303
+ const problem = instance
304
+ ? undefined
305
+ : fromEnv || fromDefault
306
+ ? broken.get(key)
307
+ : broken.values().next().value;
308
+
309
+ if (instance) selection.instance = instance;
310
+
311
+ if (problem) selection.problem = problem;
312
+
313
+ return selection;
314
+ }
315
+
316
+ /** Never a redirect: names what is running and how to start or pick one. */
317
+ export function reviewInstanceUnavailable(
318
+ selection: ReviewInstanceSelection,
319
+ ): Error {
320
+ if (selection.problem) return selection.problem;
321
+
322
+ const running = selection.instances
323
+ .filter((instance) => instance.healthy)
324
+ .map((instance) => instance.key);
325
+
326
+ const others = running.length
327
+ ? ` Running: ${running.join(", ")}.`
328
+ : " No Whiteboard is running.";
329
+
330
+ if (selection.source === "fallback" && running.length > 1)
331
+ return new ReviewInstanceUnavailableError(
332
+ `Several Whiteboard instances are running and none is selected.${others} Choose one with \`whiteboard instances use <key>\`, or \`export ${REVIEW_INSTANCE_ENV}=<key>\` for this shell.`,
333
+ );
334
+
335
+ return new ReviewInstanceUnavailableError(
336
+ `Whiteboard \`${selection.key}\` is not running. ${reviewInstanceStartHint(selection)}, or pick another instance with \`whiteboard instances\`.${others}`,
337
+ );
338
+ }
339
+
340
+ /** A command could not reach the selected Desktop instance. */
341
+ export class ReviewInstanceUnavailableError extends Error {
342
+ readonly name = "ReviewInstanceUnavailableError";
343
+ }
344
+
345
+ export function reviewInstanceStartHint(
346
+ selection: Pick<ReviewInstanceSelection, "key" | "instance">,
347
+ ): string {
348
+ if (!selection.key.startsWith("dev-"))
349
+ return "Start it with `whiteboard app launch`";
350
+ const checkout = selection.instance?.discovery.checkout;
351
+
352
+ return checkout
353
+ ? `Start it with \`pnpm dev\` in ${checkout}`
354
+ : "Start it with `pnpm dev` in its checkout";
355
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ type JsonValue,
3
+ isJsonObject,
4
+ isNumberValue,
5
+ isStringValue,
6
+ jsonValueSchema,
7
+ } from "@dev.fast/review-protocol";
8
+
9
+ /** Upgrade retained document attachments at the read/import boundary. New edits
10
+ * use the strict DiffSelection schema and never accept these retired forms. */
11
+ // This is the decoder boundary for stored documents in retired wire formats.
12
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters
13
+ export function migrateDiffSelections(input: unknown): JsonValue {
14
+ return migrateNode(jsonValueSchema.parse(input));
15
+ }
16
+
17
+ function migrateNode(value: JsonValue, attachment = false): JsonValue {
18
+ if (Array.isArray(value))
19
+ return value.map((child) => migrateNode(child, attachment));
20
+
21
+ if (!isJsonObject(value)) return value;
22
+
23
+ if (attachment && isStringValue(value.file)) {
24
+ if (isNumberValue(value.fromLine) && isNumberValue(value.toLine)) {
25
+ const side = value.side ?? value.graph ?? "head";
26
+
27
+ return {
28
+ file: value.file,
29
+ start: { side, line: value.fromLine },
30
+ end: { side, line: value.toLine },
31
+ };
32
+ }
33
+
34
+ if (
35
+ isJsonObject(value.start) &&
36
+ isJsonObject(value.end) &&
37
+ "baseLine" in value.start
38
+ ) {
39
+ const endpoint = (row: typeof value.start) =>
40
+ isNumberValue(row.headLine)
41
+ ? { side: "head", line: row.headLine }
42
+ : { side: "base", line: row.baseLine };
43
+
44
+ return {
45
+ file: value.file,
46
+ start: endpoint(value.start),
47
+ end: endpoint(value.end),
48
+ };
49
+ }
50
+ }
51
+
52
+ return Object.fromEntries(
53
+ Object.entries(value).map(([key, child]) => [
54
+ key,
55
+ migrateNode(
56
+ child,
57
+ ["source", "peek", "sources", "contextSources", "callSite"].includes(
58
+ key,
59
+ ),
60
+ ),
61
+ ]),
62
+ );
63
+ }
@@ -0,0 +1,6 @@
1
+ // Placeholder replaced by release builds (scripts/embed-posthog-key.mjs).
2
+ // Source checkouts embed no key: telemetry capture stays disabled unless
3
+ // PROGRESSIVE_REVIEW_POSTHOG_KEY (or DEV_FAST_POSTHOG_KEY / POSTHOG_KEY) is
4
+ // set in the environment.
5
+ export const EMBEDDED_PROGRESSIVE_REVIEW_POSTHOG_KEY: string | undefined =
6
+ undefined;
@@ -0,0 +1,247 @@
1
+ // Turns a raw JavaScript error into reportable telemetry properties.
2
+ //
3
+ // This module is the only place that sees raw error text on the way to PostHog.
4
+ // Callers hand it the `error` envelope of a loopback telemetry request, and it
5
+ // returns four things: the class name, the message with paths, addresses and
6
+ // secrets replaced by markers, a digest of the original message, and stack
7
+ // frames rewritten to start inside the shipped Review bundle.
8
+ //
9
+ // The message is cleaned with a port of VS Code's cleaner (telemetry-clean-text
10
+ // .ts) rather than a rule of our own, because that one is proven at scale in the
11
+ // product this is a fork of.
12
+ //
13
+ // ui-telemetry-events.ts re-checks the result independently — the frames
14
+ // against a bundle-path pattern, the message against the same secret shapes plus
15
+ // a refusal of any surviving path separator — so a bug here cannot by itself
16
+ // leak a path.
17
+
18
+ import { createHash } from "node:crypto";
19
+
20
+ import {
21
+ type JsonObject,
22
+ type JsonValue,
23
+ isJsonObject,
24
+ jsonString,
25
+ } from "@dev.fast/review-protocol";
26
+
27
+ import { cleanTelemetryText } from "./telemetry-clean-text";
28
+ import {
29
+ BUNDLE_FRAME_PATTERN,
30
+ BUNDLE_FRAME_SEPARATOR,
31
+ isReportableCleanedMessage,
32
+ } from "./ui-telemetry-events";
33
+
34
+ export interface DerivedErrorTelemetryProperties {
35
+ error_name?: string;
36
+ message?: string;
37
+ message_hash?: string;
38
+ frames?: string;
39
+ }
40
+
41
+ /**
42
+ * Errors whose message quotes the document being checked. A review tool runs
43
+ * authored prose through schemas, so these carry review content by design —
44
+ * unlike an editor, which mostly reports on files. Keep the class, the digest
45
+ * and the frames; drop the message. VS Code drops file-operation errors and
46
+ * errors carrying a system code for the same reason.
47
+ */
48
+ const SCHEMA_ERROR_NAMES = new Set(["ZodError", "ValidationError"]);
49
+
50
+ const MESSAGE_HASH_LENGTH = 16;
51
+
52
+ const MAX_FRAMES = 10;
53
+
54
+ const MAX_STACK_LENGTH = 16_384;
55
+
56
+ const MAX_ERROR_NAME_LENGTH = 40;
57
+
58
+ const ERROR_NAME_PATTERN = /^[A-Za-z0-9_$-]+$/;
59
+
60
+ /**
61
+ * Path segments that exist only inside the shipped bundle. A frame keeps the
62
+ * text that follows the LAST occurrence of one of these and discards everything
63
+ * before it, which is what removes the absolute prefix. A frame with no such
64
+ * segment is not ours, so it is dropped.
65
+ */
66
+ const BUNDLE_ANCHORS = ["/out/", "/assets/", "/review-runtime/"];
67
+
68
+ /** `at fn (url:line:col)`, `at url:line:col`, and the bare `url:line:col`. */
69
+ const STACK_FRAME_PATTERN = /(?:\(|\bat\s+|^\s*)([^()\s]+?):(\d+):(\d+)\)?\s*$/;
70
+
71
+ /**
72
+ * `cause` is the raw error envelope a client attached beside its event
73
+ * properties: `{ name, message, stack }` as JSON, or anything else a hostile
74
+ * client sent, which derives nothing.
75
+ */
76
+ export function deriveErrorTelemetryProperties(
77
+ cause: unknown,
78
+ ): DerivedErrorTelemetryProperties {
79
+ const derived: DerivedErrorTelemetryProperties = {};
80
+
81
+ try {
82
+ if (!isJsonObject(cause)) return derived;
83
+
84
+ const name = jsonString(cause.name);
85
+
86
+ if (
87
+ name !== undefined &&
88
+ name.length > 0 &&
89
+ name.length <= MAX_ERROR_NAME_LENGTH &&
90
+ ERROR_NAME_PATTERN.test(name)
91
+ ) {
92
+ derived.error_name = name;
93
+ }
94
+
95
+ const message = jsonString(cause.message);
96
+
97
+ if (message !== undefined && message.length > 0) {
98
+ // The digest goes on every report, cleaned message or not. It is what
99
+ // groups the reports whose message does not survive the checks below.
100
+ derived.message_hash = hashErrorMessage(message);
101
+
102
+ if (!SCHEMA_ERROR_NAMES.has(derived.error_name ?? "")) {
103
+ const cleaned = cleanTelemetryText(message, NO_DELETED_DIRECTORIES);
104
+
105
+ // Ask the allowlist's own check before sending. Failing here rather
106
+ // than there keeps a message that the cleaner could not finish out of
107
+ // the payload entirely, instead of relying on the later gate.
108
+ if (isReportableCleanedMessage(cleaned)) derived.message = cleaned;
109
+ }
110
+ }
111
+
112
+ const frames = packBundleFrames(cause.stack);
113
+
114
+ if (frames) derived.frames = frames;
115
+ } catch {
116
+ // Telemetry must never break the request that carried it.
117
+ }
118
+
119
+ return derived;
120
+ }
121
+
122
+ /**
123
+ * Properties only this module may produce, because each is a claim about work
124
+ * the server did that the allowlist cannot check for itself: whether a message
125
+ * was cleaned, and whether a digest or a frame list really came from this
126
+ * error. A client that sent one directly would reach the allowlist with no
127
+ * cleaning behind it.
128
+ *
129
+ * `error_name` is deliberately absent. The allowlist caps it at 40 identifier
130
+ * characters, which makes it safe whoever sends it, and `bug_report_send_failed`
131
+ * sends it with no error envelope.
132
+ */
133
+ const SERVER_DERIVED_PROPERTIES = [
134
+ "message",
135
+ "message_hash",
136
+ "frames",
137
+ ] as const;
138
+
139
+ /**
140
+ * Merge a client's event properties with the ones derived from its raw error.
141
+ * This is the only supported way to build an error-bearing telemetry payload:
142
+ * it removes any server-derived property the client tried to assert before
143
+ * adding the real ones.
144
+ */
145
+ export function mergeErrorTelemetryProperties(
146
+ clientProperties: JsonObject,
147
+ cause: unknown,
148
+ ): JsonObject {
149
+ const merged = { ...clientProperties };
150
+
151
+ for (const key of SERVER_DERIVED_PROPERTIES) delete merged[key];
152
+
153
+ if (cause) Object.assign(merged, deriveErrorTelemetryProperties(cause));
154
+
155
+ return merged;
156
+ }
157
+
158
+ /**
159
+ * Review deliberately gives the cleaner NO directories to delete, which is the
160
+ * one place its configuration differs from VS Code's.
161
+ *
162
+ * VS Code passes five — its app root, extensions path, user data path, home
163
+ * directory and temporary directory — and deletes each prefix outright so the
164
+ * remainder reads as a relative path. That is right for a stack trace, where
165
+ * the remainder is VS Code's own file. It is wrong here: a path under the home
166
+ * directory then keeps everything after it, so
167
+ * `/Users/you/work/acme-repo/plan.md` would be sent as
168
+ * `/work/acme-repo/plan.md` — the repository name intact. A review tool cannot
169
+ * disclose that.
170
+ *
171
+ * With no directories to delete, the cleaner's overlap check never fires and
172
+ * the whole path is replaced by a marker instead, which is what we want. Frames
173
+ * are unaffected: they are anchored on the bundle directory separately.
174
+ */
175
+ const NO_DELETED_DIRECTORIES: RegExp[] = [];
176
+
177
+ /**
178
+ * A stable fingerprint of the message. Two reports with the same digest had the
179
+ * same cause; the digest itself reveals nothing, because it is one-way and the
180
+ * message is never sent alongside it.
181
+ */
182
+ export function hashErrorMessage(message: string): string {
183
+ return createHash("sha256")
184
+ .update(message, "utf8")
185
+ .digest("hex")
186
+ .slice(0, MESSAGE_HASH_LENGTH);
187
+ }
188
+
189
+ /**
190
+ * Rewrite a stack into bundle-relative `file:line:col` frames joined by "|".
191
+ * Returns undefined when no frame resolves inside the bundle.
192
+ */
193
+ export function packBundleFrames(
194
+ stack: JsonValue | undefined,
195
+ ): string | undefined {
196
+ const text = Array.isArray(stack) ? stack.join("\n") : jsonString(stack);
197
+
198
+ if (!text) return undefined;
199
+
200
+ const frames: string[] = [];
201
+
202
+ for (const line of text.slice(0, MAX_STACK_LENGTH).split("\n")) {
203
+ const match = STACK_FRAME_PATTERN.exec(line);
204
+
205
+ if (!match) continue;
206
+ const file = bundleRelativePath(match[1]);
207
+
208
+ if (!file) continue;
209
+ const frame = `${file}:${match[2]}:${match[3]}`;
210
+
211
+ // A user directory can be called "out" too, so anchoring alone is not
212
+ // enough: the result must also start inside a known bundle directory.
213
+ if (!BUNDLE_FRAME_PATTERN.test(frame)) continue;
214
+ frames.push(frame);
215
+
216
+ if (frames.length >= MAX_FRAMES) break;
217
+ }
218
+
219
+ return frames.length > 0 ? frames.join(BUNDLE_FRAME_SEPARATOR) : undefined;
220
+ }
221
+
222
+ function bundleRelativePath(location: string): string | undefined {
223
+ // Drop a query or fragment first: either can carry arbitrary text, and a
224
+ // cache-busting query is common on the canvas bundle.
225
+ const clean = location.split(/[?#]/, 1)[0];
226
+ let cut = -1;
227
+ let anchorLength = 0;
228
+
229
+ for (const anchor of BUNDLE_ANCHORS) {
230
+ const index = clean.lastIndexOf(anchor);
231
+
232
+ if (index > cut) {
233
+ cut = index;
234
+ anchorLength = anchor.length;
235
+ }
236
+ }
237
+
238
+ if (cut < 0) return undefined;
239
+ const relative = clean.slice(cut + anchorLength);
240
+ // "/review-runtime/" and "/assets/" name the directory the frame lives in, so
241
+ // put it back; "/out/" is a build directory and is not part of the path we
242
+ // report.
243
+ const prefix = clean.slice(cut + 1, cut + anchorLength);
244
+ const path = prefix === "out/" ? relative : `${prefix}${relative}`;
245
+
246
+ return path.length > 0 ? path : undefined;
247
+ }
@@ -0,0 +1,14 @@
1
+ /** Quote matching shared by legacy publish and JSON accept: authors paste
2
+ * transcript text with arbitrary line wrapping, so both sides compare on
3
+ * whitespace-normalized text. */
4
+ export function normalizeQuoteText(text: string): string {
5
+ return text.trim().replace(/\s+/g, " ");
6
+ }
7
+
8
+ export function textIncludesQuote(text: string, quote: string): boolean {
9
+ const normalizedQuote = normalizeQuoteText(quote);
10
+
11
+ return (
12
+ normalizedQuote !== "" && normalizeQuoteText(text).includes(normalizedQuote)
13
+ );
14
+ }