@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,198 @@
1
+ import {
2
+ type JsonObject,
3
+ type JsonValue,
4
+ isJsonObject,
5
+ jsonString,
6
+ } from "@dev.fast/review-protocol";
7
+ import { mergeErrorTelemetryProperties } from "@review/error-telemetry";
8
+ import type {
9
+ ReviewTabTelemetryEvent,
10
+ ReviewTelemetryContext,
11
+ } from "@review/telemetry";
12
+ import {
13
+ REVIEW_APP_SESSION_ID_HEADER,
14
+ sanitizeUiTelemetryEvent,
15
+ } from "@review/ui-telemetry-events";
16
+ import { z } from "zod";
17
+
18
+ import { ClientErrorBudget } from "./client-error-budget";
19
+
20
+ const contextSchema = z.object({
21
+ reviewUuid: z.string().min(1).max(128).optional(),
22
+ presentationSessionId: z.string().min(1).max(128).optional(),
23
+ });
24
+
25
+ type SanitizedUiTelemetryEvent = NonNullable<
26
+ ReturnType<typeof sanitizeUiTelemetryEvent>
27
+ >;
28
+
29
+ const clientErrorBudget = new ClientErrorBudget();
30
+
31
+ /**
32
+ * Applies the per-session, per-digest error budget to a sanitized event, and
33
+ * returns what to send in its place: the event itself, one
34
+ * review_error_burst that says how many were withheld, or nothing. Every path
35
+ * that reports review_client_error goes through here. Other events, and errors
36
+ * without a session or a digest, pass unchanged.
37
+ */
38
+ export function admitUiTelemetryEvent(
39
+ event: SanitizedUiTelemetryEvent,
40
+ ): SanitizedUiTelemetryEvent | undefined {
41
+ if (event.event !== "review_client_error") return event;
42
+ const sessionId = jsonString(event.properties.app_session_id);
43
+ const messageHash = jsonString(event.properties.message_hash);
44
+
45
+ if (sessionId === undefined || messageHash === undefined) return event;
46
+ const admission = clientErrorBudget.admit(sessionId, messageHash);
47
+
48
+ if (admission.verdict === "send") return event;
49
+
50
+ if (admission.verdict === "drop") return undefined;
51
+
52
+ return (
53
+ sanitizeUiTelemetryEvent({
54
+ name: "error_burst",
55
+ properties: {
56
+ message_hash: messageHash,
57
+ suppressed: admission.suppressed,
58
+ app_session_id: sessionId,
59
+ },
60
+ }) ?? undefined
61
+ );
62
+ }
63
+
64
+ const MAX_CLIENT_ERROR_SESSIONS = 100;
65
+
66
+ const MAX_CLIENT_ERRORS_PER_SESSION = 20;
67
+
68
+ const clientErrorsBySession = new Map<string, string[]>();
69
+
70
+ export function recordClientError(
71
+ event: ReturnType<typeof sanitizeUiTelemetryEvent>,
72
+ ): void {
73
+ if (event?.event !== "review_client_error") return;
74
+ const sessionId = jsonString(event.properties.app_session_id);
75
+ const errorName = jsonString(event.properties.error_name);
76
+
77
+ if (sessionId === undefined || errorName === undefined) return;
78
+ const names = clientErrorsBySession.get(sessionId) ?? [];
79
+ names.push(errorName);
80
+
81
+ if (names.length > MAX_CLIENT_ERRORS_PER_SESSION) names.shift();
82
+ clientErrorsBySession.delete(sessionId);
83
+ clientErrorsBySession.set(sessionId, names);
84
+
85
+ while (clientErrorsBySession.size > MAX_CLIENT_ERROR_SESSIONS) {
86
+ const oldest = clientErrorsBySession.keys().next().value;
87
+
88
+ if (oldest === undefined) break;
89
+ clientErrorsBySession.delete(oldest);
90
+ }
91
+ }
92
+
93
+ export function clientErrorsForSession(sessionId: string): string[] {
94
+ const names = clientErrorsBySession.get(sessionId) ?? [];
95
+
96
+ if (names.length > 0) {
97
+ clientErrorsBySession.delete(sessionId);
98
+ clientErrorsBySession.set(sessionId, names);
99
+ }
100
+
101
+ return [...names];
102
+ }
103
+
104
+ export interface ReviewTelemetryCapture {
105
+ captureTabViewed(event: ReviewTabTelemetryEvent): Promise<void>;
106
+ captureUiEvent?(
107
+ event: string,
108
+ properties: Record<string, string | number | boolean>,
109
+ context?: ReviewTelemetryContext,
110
+ occurredAt?: number,
111
+ ): Promise<void>;
112
+ }
113
+
114
+ /** How far back a client may date its own event. */
115
+ const MAX_CLIENT_EVENT_AGE_MS = 5 * 60 * 1_000;
116
+
117
+ /**
118
+ * The client's occurrence time, clamped to the recent past: parallel requests
119
+ * can arrive out of order, but a client can never date an event ahead of now
120
+ * or far behind it.
121
+ */
122
+ export function clientOccurredAt(
123
+ raw: JsonValue | undefined,
124
+ now: number,
125
+ ): number {
126
+ const parsed = z.number().safeParse(raw);
127
+
128
+ if (!parsed.success) return now;
129
+
130
+ return Math.min(now, Math.max(now - MAX_CLIENT_EVENT_AGE_MS, parsed.data));
131
+ }
132
+
133
+ export async function captureSanitizedUiTelemetry(
134
+ telemetry: ReviewTelemetryCapture,
135
+ request: Request,
136
+ name: JsonValue,
137
+ properties: JsonValue,
138
+ onSanitized?: (
139
+ event: SanitizedUiTelemetryEvent,
140
+ context: ReviewTelemetryContext | undefined,
141
+ ) => void,
142
+ /**
143
+ * The raw error envelope, which arrives beside `properties` and never inside
144
+ * it. This function is where the raw form dies: what continues is the class
145
+ * name, the message with paths and secrets replaced by markers, a digest of
146
+ * the original message, and bundle-relative frames. The allowlist re-checks
147
+ * all of it. Never merge this into `properties`.
148
+ */
149
+ rawError?: JsonValue,
150
+ /**
151
+ * Raw review and presentation ids, beside `properties` like `error`. They
152
+ * never reach PostHog: the telemetry API replaces them with keyed HMACs.
153
+ */
154
+ rawContext?: JsonValue,
155
+ /** The client's `occurredAt`, epoch ms; see {@link clientOccurredAt}. */
156
+ rawOccurredAt?: JsonValue,
157
+ ): Promise<void> {
158
+ const occurredAt = clientOccurredAt(rawOccurredAt, Date.now());
159
+
160
+ const appSessionId =
161
+ request.headers.get(REVIEW_APP_SESSION_ID_HEADER) ?? undefined;
162
+
163
+ const rawProperties: JsonObject = isJsonObject(properties) ? properties : {};
164
+
165
+ // The error fields come from the raw envelope and nowhere else; this
166
+ // helper drops any a client tried to assert. It matters because the
167
+ // allowlist cannot tell a cleaned message from a raw one.
168
+ const mergedProperties = mergeErrorTelemetryProperties(
169
+ rawProperties,
170
+ rawError,
171
+ );
172
+
173
+ if (appSessionId) mergedProperties.app_session_id = appSessionId;
174
+
175
+ const sanitized = sanitizeUiTelemetryEvent({
176
+ name,
177
+ properties: mergedProperties,
178
+ });
179
+
180
+ if (!sanitized) return;
181
+ const admitted = admitUiTelemetryEvent(sanitized);
182
+
183
+ if (!admitted) return;
184
+ const parsedContext = contextSchema.safeParse(rawContext);
185
+ const context = parsedContext.success ? parsedContext.data : undefined;
186
+ onSanitized?.(admitted, context);
187
+
188
+ try {
189
+ await telemetry.captureUiEvent?.(
190
+ admitted.event,
191
+ admitted.properties,
192
+ context,
193
+ occurredAt,
194
+ );
195
+ } catch (error) {
196
+ console.error(error);
197
+ }
198
+ }
@@ -0,0 +1,95 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { z } from "zod";
5
+
6
+ import { devReviewHome } from "./review-home-paths.js";
7
+
8
+ const discoverySchema = z.object({
9
+ version: z.literal(1),
10
+ instanceId: z.uuid(),
11
+ url: z.url().refine((value) => {
12
+ const url = new URL(value);
13
+
14
+ return url.protocol === "http:" && url.hostname === "127.0.0.1";
15
+ }),
16
+ serverPid: z.number().int().positive(),
17
+ token: z.string().min(1),
18
+ });
19
+
20
+ export type ReviewServerDiscovery = z.infer<typeof discoverySchema>;
21
+
22
+ export function reviewServerStateDir(env: NodeJS.ProcessEnv = process.env) {
23
+ return env.DEV_REVIEW_SERVER_DIR?.trim()
24
+ ? path.resolve(env.DEV_REVIEW_SERVER_DIR.trim())
25
+ : devReviewHome(env);
26
+ }
27
+
28
+ export function reviewServerDiscoveryPath(stateDir: string) {
29
+ return path.join(stateDir, "review-server", "server.json");
30
+ }
31
+
32
+ export function headlessServerLockPath(stateDir: string) {
33
+ return path.join(stateDir, "headless-server.lock");
34
+ }
35
+
36
+ export async function readReviewServerDiscovery(
37
+ stateDir: string,
38
+ ): Promise<ReviewServerDiscovery | null> {
39
+ let source: string;
40
+
41
+ try {
42
+ source = await readFile(reviewServerDiscoveryPath(stateDir), "utf8");
43
+ } catch (error) {
44
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
45
+ return null;
46
+ throw error;
47
+ }
48
+
49
+ try {
50
+ return discoverySchema.parse(JSON.parse(source));
51
+ } catch {
52
+ throw new Error(
53
+ `Whiteboard server discovery in ${stateDir} is invalid or incompatible. Restart it with this version of whiteboard server start.`,
54
+ );
55
+ }
56
+ }
57
+
58
+ export async function reviewServerIsHealthy(discovery: ReviewServerDiscovery) {
59
+ return (await readReviewServerHealth(discovery)) !== null;
60
+ }
61
+
62
+ /** The recorded server's /health, or null when another or none answers. */
63
+ export async function readReviewServerHealth(
64
+ discovery: Pick<ReviewServerDiscovery, "url" | "token" | "instanceId">,
65
+ ) {
66
+ try {
67
+ const response = await fetch(`${discovery.url}/health`, {
68
+ headers: { "x-review-token": discovery.token },
69
+ signal: AbortSignal.timeout(1_500),
70
+ });
71
+
72
+ if (!response.ok) return null;
73
+
74
+ const health = z
75
+ .object({
76
+ ok: z.literal(true),
77
+ instanceId: z.string(),
78
+ serverId: z.string().optional(),
79
+ version: z.string().optional(),
80
+ })
81
+ .safeParse(await response.json());
82
+
83
+ return health.success && health.data.instanceId === discovery.instanceId
84
+ ? health.data
85
+ : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ export function serverNotReady(stateDir: string) {
92
+ return new Error(
93
+ `Whiteboard server is not ready in ${stateDir}. Run whiteboard server start --state-dir ${JSON.stringify(stateDir)}, then retry.`,
94
+ );
95
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { parseJsonText } from "@dev.fast/review-protocol";
5
+ import { writeFileAtomic } from "@dev.fast/trace-core";
6
+ import { z } from "zod";
7
+
8
+ import type { PostHogCaptureProperties } from "./posthog-capture-client";
9
+ import { devReviewHome } from "./review-home-paths";
10
+ import { reviewTelemetryChannel, uuidV7Schema } from "./telemetry-config";
11
+
12
+ /**
13
+ * The envelope fields of the launch that opened a session which a later
14
+ * launch may not share. An abnormal end carries them so it counts against
15
+ * the launch that died, not the one that reported it. Closed values only.
16
+ */
17
+ const launchEnvelopeSchema = z.object({
18
+ app_version: z.string().min(1).optional(),
19
+ cli_version: z.string().min(1),
20
+ version: z.string().min(1),
21
+ channel: z.enum(["stable", "preview", "dev"]),
22
+ environment: z.enum(["production", "ci", "internal", "e2e", "smoke"]),
23
+ surface: z.enum(["desktop", "cli", "headless", "mcp", "api"]),
24
+ node_major: z.number().int(),
25
+ arch: z.string().min(1),
26
+ os_version: z.string().min(1),
27
+ ci: z.boolean(),
28
+ internal: z.boolean(),
29
+ $session_id: uuidV7Schema.optional(),
30
+ install_age_days: z.number().int().nonnegative().optional(),
31
+ });
32
+
33
+ export type LaunchEnvelope = z.infer<typeof launchEnvelopeSchema>;
34
+
35
+ /** Picks the launch fields out of an event envelope; undefined if malformed. */
36
+ export function launchEnvelope(
37
+ properties: PostHogCaptureProperties,
38
+ ): LaunchEnvelope | undefined {
39
+ return launchEnvelopeSchema.safeParse(properties).data;
40
+ }
41
+
42
+ /**
43
+ * A review session the Desktop opened and has not yet reported as ended. The
44
+ * next launch turns any survivor into `review_session_ended{outcome:abnormal}`,
45
+ * which is the floor under crash and hang counts.
46
+ */
47
+ export interface OpenSessionMarker {
48
+ presentationSessionId: string;
49
+ reviewUuid: string;
50
+ startedAt: number;
51
+ appSessionId?: string;
52
+ /**
53
+ * The process that owns the session: the Desktop's Electron main for its
54
+ * server. A live owner means the session is still open, whichever launch
55
+ * reconciles.
56
+ */
57
+ ownerPid?: number;
58
+ /** Absent on markers written before launches recorded their envelope. */
59
+ envelope?: LaunchEnvelope;
60
+ }
61
+
62
+ const markersSchema = z.array(
63
+ z.object({
64
+ presentationSessionId: z.string().min(1),
65
+ reviewUuid: z.string().min(1),
66
+ startedAt: z.number(),
67
+ appSessionId: z.string().min(1).optional(),
68
+ ownerPid: z.number().int().positive().optional(),
69
+ // A malformed envelope degrades to a legacy marker, not a lost one.
70
+ envelope: launchEnvelopeSchema.optional().catch(undefined),
71
+ }),
72
+ );
73
+
74
+ /** Per channel, like the telemetry config: each channel has its own identity. */
75
+ export function openSessionMarkersPath(env: NodeJS.ProcessEnv): string {
76
+ return path.join(
77
+ devReviewHome(env),
78
+ "telemetry",
79
+ reviewTelemetryChannel(env) === "preview"
80
+ ? "open-sessions.preview.json"
81
+ : "open-sessions.json",
82
+ );
83
+ }
84
+
85
+ function readMarkers(file: string): OpenSessionMarker[] {
86
+ try {
87
+ const parsed = markersSchema.safeParse(
88
+ parseJsonText(readFileSync(file, "utf8")),
89
+ );
90
+
91
+ return parsed.success ? parsed.data : [];
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ function writeMarkers(file: string, markers: OpenSessionMarker[]): void {
98
+ writeFileAtomic(file, `${JSON.stringify(markers)}\n`, "utf8");
99
+ }
100
+
101
+ export function recordOpenSession(
102
+ file: string,
103
+ marker: OpenSessionMarker,
104
+ ): void {
105
+ const others = readMarkers(file).filter(
106
+ (existing) =>
107
+ existing.presentationSessionId !== marker.presentationSessionId,
108
+ );
109
+
110
+ writeMarkers(file, [...others, marker]);
111
+ }
112
+
113
+ export function clearOpenSession(
114
+ file: string,
115
+ presentationSessionId: string,
116
+ ): void {
117
+ if (!existsSync(file)) return;
118
+ writeMarkers(
119
+ file,
120
+ readMarkers(file).filter(
121
+ (marker) => marker.presentationSessionId !== presentationSessionId,
122
+ ),
123
+ );
124
+ }
125
+
126
+ /** Reads every marker and deletes the file. */
127
+ export function takeOpenSessions(file: string): OpenSessionMarker[] {
128
+ const markers = readMarkers(file);
129
+ rmSync(file, { force: true });
130
+
131
+ return markers;
132
+ }
@@ -0,0 +1,41 @@
1
+ import { DEFAULT_STORE_ORIGIN, readStoreAuth } from "@dev.fast/trace-core";
2
+ import { ReviewInputError } from "@review/review-api/document.js";
3
+
4
+ /** CI credentials stay in the server environment and are never persisted to a profile. */
5
+ export async function readSharingAuth(env: NodeJS.ProcessEnv = process.env) {
6
+ if (env.DEV_REVIEW_SHARE_TOKEN === undefined) {
7
+ if (env.DEV_REVIEW_SHARE_ORIGIN !== undefined)
8
+ throw new ReviewInputError(
9
+ "DEV_REVIEW_SHARE_ORIGIN requires DEV_REVIEW_SHARE_TOKEN.",
10
+ );
11
+
12
+ return readStoreAuth(env);
13
+ }
14
+
15
+ const token = env.DEV_REVIEW_SHARE_TOKEN.trim();
16
+
17
+ if (!token) throw new ReviewInputError("DEV_REVIEW_SHARE_TOKEN is empty.");
18
+ let url: URL;
19
+
20
+ try {
21
+ url = new URL(env.DEV_REVIEW_SHARE_ORIGIN ?? DEFAULT_STORE_ORIGIN);
22
+ } catch {
23
+ throw new ReviewInputError(
24
+ "DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.",
25
+ );
26
+ }
27
+
28
+ if (
29
+ url.protocol !== "https:" ||
30
+ url.username ||
31
+ url.password ||
32
+ url.pathname !== "/" ||
33
+ url.search ||
34
+ url.hash
35
+ )
36
+ throw new ReviewInputError(
37
+ "DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.",
38
+ );
39
+
40
+ return { origin: url.origin, token };
41
+ }
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Writable } from "node:stream";
3
+
4
+ import { connectReviewApi } from "@review/review-api/agent-client.js";
5
+ import { z } from "zod";
6
+
7
+ const resultSchema = z.strictObject({
8
+ shareId: z.uuid(),
9
+ version: z.number().int(),
10
+ url: z.url(),
11
+ });
12
+
13
+ export async function runShareCli(input: {
14
+ review?: string;
15
+ version?: string;
16
+ requestId?: string;
17
+ preview?: boolean;
18
+ revoke?: string;
19
+ json?: boolean;
20
+ env?: NodeJS.ProcessEnv;
21
+ stdout: Writable;
22
+ stderr: Writable;
23
+ }) {
24
+ try {
25
+ const client = await connectReviewApi(input.env);
26
+
27
+ if (input.revoke) {
28
+ const result = z
29
+ .strictObject({ shareId: z.uuid(), revoked: z.literal(true) })
30
+ .parse(await client.post("/sharing/revoke", { shareId: input.revoke }));
31
+
32
+ input.stdout.write(
33
+ input.json
34
+ ? JSON.stringify(result) + "\n"
35
+ : "Share revoked. Existing downloads remain available offline.\n",
36
+ );
37
+ } else {
38
+ if (!input.review) throw new Error("Use whiteboard share --review <id>.");
39
+
40
+ const version =
41
+ input.version === undefined
42
+ ? undefined
43
+ : z.number().int().nonnegative().parse(Number(input.version));
44
+
45
+ input.stderr.write(
46
+ "Sharing includes retained images, maps, and full trace conversations. Anyone with the link can download them. Reviews with code require GitHub repository access to fetch the pinned commits.\n",
47
+ );
48
+
49
+ const result = resultSchema.parse(
50
+ await client.post("/sharing/publish", {
51
+ reviewId: input.review,
52
+ version,
53
+ requestId:
54
+ input.requestId === undefined
55
+ ? randomUUID()
56
+ : z.uuid().parse(input.requestId),
57
+ }),
58
+ );
59
+
60
+ if (input.preview) {
61
+ const url = new URL(result.url);
62
+ url.searchParams.set("app", "preview");
63
+ result.url = url.href;
64
+ }
65
+
66
+ input.stdout.write(
67
+ input.json ? JSON.stringify(result) + "\n" : result.url + "\n",
68
+ );
69
+ }
70
+
71
+ return 0;
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : "Sharing failed.";
74
+
75
+ if (input.json)
76
+ input.stdout.write(
77
+ JSON.stringify({ error: { code: "share_failed", message } }) + "\n",
78
+ );
79
+ input.stderr.write(message + "\n");
80
+
81
+ return 1;
82
+ }
83
+ }