@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.
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,174 @@
1
+ // Reports the server process's own uncaught errors as review_client_error,
2
+ // through the same cleaner, allowlist and per-session budget as every other
3
+ // client error.
4
+ //
5
+ // Whether an uncaught error is fatal depends on the host. The Desktop utility
6
+ // process runs behind VS Code's bootstrap-fork, which installs uncaughtException
7
+ // and unhandledRejection handlers that log and keep the process up. Headless
8
+ // runs as plain Node, where both exit. This module never adds a handler that
9
+ // would change that: it observes through uncaughtExceptionMonitor, and watches
10
+ // rejections only when something already handles them.
11
+ //
12
+ // A fatal error exits before any network send can finish, so it is written to a
13
+ // file synchronously and sent by the next server start.
14
+
15
+ import { mkdirSync, writeFileSync } from "node:fs";
16
+ import { readFile, rm } from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ import {
20
+ type JsonObject,
21
+ type JsonValue,
22
+ parseJsonText,
23
+ } from "@dev.fast/review-protocol";
24
+ import { mergeErrorTelemetryProperties } from "@review/error-telemetry";
25
+ import { devReviewHome } from "@review/review-home-paths";
26
+ import { sanitizeUiTelemetryEvent } from "@review/ui-telemetry-events";
27
+
28
+ import {
29
+ type ReviewTelemetryCapture,
30
+ admitUiTelemetryEvent,
31
+ } from "./ui-telemetry";
32
+
33
+ type ErrorTelemetry = Pick<ReviewTelemetryCapture, "captureUiEvent">;
34
+
35
+ /** The slice of `process` this module uses; tests pass an EventEmitter. */
36
+ type ProcessErrorEvents = Pick<
37
+ NodeJS.EventEmitter,
38
+ "on" | "off" | "listenerCount"
39
+ >;
40
+
41
+ export function serverCrashReportPath(
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): string {
44
+ return path.join(devReviewHome(env), "telemetry", "server-crash.json");
45
+ }
46
+
47
+ export function installProcessErrorTelemetry(
48
+ telemetry: ErrorTelemetry,
49
+ options: {
50
+ process?: ProcessErrorEvents;
51
+ appSessionId?: string;
52
+ crashReportPath?: string;
53
+ } = {},
54
+ ): () => void {
55
+ const target = options.process ?? process;
56
+ const crashReportPath = options.crashReportPath ?? serverCrashReportPath();
57
+
58
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- process event boundary
59
+ const onUncaught = (cause: unknown) => {
60
+ const properties = serverErrorProperties(
61
+ toError(cause),
62
+ options.appSessionId,
63
+ );
64
+
65
+ // With no uncaughtException handler Node exits once the monitors return.
66
+ if (target.listenerCount("uncaughtException") === 0)
67
+ writeCrashReport(crashReportPath, properties);
68
+ else void sendServerError(telemetry, properties);
69
+ };
70
+
71
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- process event boundary
72
+ const onRejection = (cause: unknown) => {
73
+ void sendServerError(
74
+ telemetry,
75
+ serverErrorProperties(toError(cause), options.appSessionId),
76
+ );
77
+ };
78
+
79
+ // A first unhandledRejection listener would stop Node from throwing
80
+ // unhandled rejections. Without one they arrive at the monitor instead.
81
+ const watchRejections = target.listenerCount("unhandledRejection") > 0;
82
+
83
+ target.on("uncaughtExceptionMonitor", onUncaught);
84
+
85
+ if (watchRejections) target.on("unhandledRejection", onRejection);
86
+
87
+ return () => {
88
+ target.off("uncaughtExceptionMonitor", onUncaught);
89
+
90
+ if (watchRejections) target.off("unhandledRejection", onRejection);
91
+ };
92
+ }
93
+
94
+ /** Sends, once, the fatal error an earlier server process left behind. */
95
+ export async function drainServerCrashReport(
96
+ telemetry: ErrorTelemetry,
97
+ crashReportPath: string = serverCrashReportPath(),
98
+ ): Promise<void> {
99
+ let properties: JsonValue;
100
+
101
+ try {
102
+ const text = await readFile(crashReportPath, "utf8");
103
+ await rm(crashReportPath, { force: true });
104
+ properties = parseJsonText(text);
105
+ } catch {
106
+ return;
107
+ }
108
+
109
+ await sendServerError(telemetry, properties);
110
+ }
111
+
112
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- process event boundary
113
+ function toError(cause: unknown): Error {
114
+ return cause instanceof Error ? cause : new Error(String(cause));
115
+ }
116
+
117
+ function serverErrorProperties(error: Error, appSessionId?: string) {
118
+ const properties: JsonObject = {
119
+ error_source: "server_unexpected",
120
+ error_process: "server",
121
+ error_name: error.name,
122
+ };
123
+
124
+ if (appSessionId) properties.app_session_id = appSessionId;
125
+
126
+ return mergeErrorTelemetryProperties(properties, {
127
+ name: error.name,
128
+ message: error.message,
129
+ stack: error.stack ?? "",
130
+ });
131
+ }
132
+
133
+ function writeCrashReport(
134
+ crashReportPath: string,
135
+ properties: JsonObject,
136
+ ): void {
137
+ // Only what the allowlist accepts is written, so the file holds nothing
138
+ // that could not have been sent.
139
+ const sanitized = sanitizeUiTelemetryEvent({
140
+ name: "client_error",
141
+ properties,
142
+ });
143
+
144
+ if (!sanitized) return;
145
+
146
+ try {
147
+ mkdirSync(path.dirname(crashReportPath), { recursive: true });
148
+ writeFileSync(crashReportPath, JSON.stringify(sanitized.properties), {
149
+ mode: 0o600,
150
+ });
151
+ } catch {
152
+ // The process is exiting; there is nowhere left to report this.
153
+ }
154
+ }
155
+
156
+ async function sendServerError(
157
+ telemetry: ErrorTelemetry,
158
+ properties: JsonValue,
159
+ ): Promise<void> {
160
+ const sanitized = sanitizeUiTelemetryEvent({
161
+ name: "client_error",
162
+ properties,
163
+ });
164
+
165
+ const admitted = sanitized ? admitUiTelemetryEvent(sanitized) : undefined;
166
+
167
+ if (!admitted) return;
168
+
169
+ try {
170
+ await telemetry.captureUiEvent?.(admitted.event, admitted.properties);
171
+ } catch {
172
+ // Telemetry must never add a second error to the one being reported.
173
+ }
174
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ type JsonValue,
3
+ ReviewBugReportRequestSchema,
4
+ parseZod,
5
+ } from "@dev.fast/review-protocol";
6
+ import type { ReviewTabTelemetryEvent } from "@review/telemetry";
7
+ import { REVIEW_TELEMETRY_TABS } from "@review/ui-telemetry-events";
8
+ import { z } from "zod";
9
+
10
+ import { HttpJsonError } from "./http-json";
11
+
12
+ const MIN_REVIEW_TAB_DWELL_MS = 250;
13
+
14
+ const MAX_REVIEW_TAB_DWELL_MS = 4 * 60 * 60 * 1_000;
15
+
16
+ const APP_SESSION_ID_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9_.-]{15,127}$/;
17
+
18
+ const MAX_BUG_REPORT_DESCRIPTION_BYTES = 64 * 1024;
19
+
20
+ const MAX_BUG_REPORT_SCREENSHOT_BYTES = 3 * 1024 * 1024;
21
+
22
+ export function parseReviewBugReportInput(value: JsonValue) {
23
+ const parsed = ReviewBugReportRequestSchema.parse(value);
24
+
25
+ if (
26
+ Buffer.byteLength(parsed.description, "utf8") >
27
+ MAX_BUG_REPORT_DESCRIPTION_BYTES
28
+ ) {
29
+ throw new HttpJsonError(
30
+ `description must not exceed ${MAX_BUG_REPORT_DESCRIPTION_BYTES} UTF-8 bytes`,
31
+ 413,
32
+ );
33
+ }
34
+
35
+ if (parsed.screenshot) {
36
+ const screenshot = Buffer.from(parsed.screenshot.base64, "base64");
37
+
38
+ if (screenshot.toString("base64") !== parsed.screenshot.base64) {
39
+ throw new HttpJsonError("screenshot.base64 must be valid base64", 400);
40
+ }
41
+
42
+ if (screenshot.byteLength > MAX_BUG_REPORT_SCREENSHOT_BYTES) {
43
+ throw new HttpJsonError(
44
+ `screenshot must not exceed ${MAX_BUG_REPORT_SCREENSHOT_BYTES} decoded bytes`,
45
+ 413,
46
+ );
47
+ }
48
+ }
49
+
50
+ return parsed;
51
+ }
52
+
53
+ export const ReviewTabTelemetryInputSchema = z
54
+ .strictObject({
55
+ tab: z.enum(REVIEW_TELEMETRY_TABS, {
56
+ error: "must be review, commits, map, files, or trace",
57
+ }),
58
+ reason: z.enum(["tab_change", "visibility_hidden", "pagehide", "unmount"], {
59
+ error: "must be tab_change, visibility_hidden, pagehide, or unmount",
60
+ }),
61
+ duration_ms: z
62
+ .number()
63
+ .int("must be an integer from 250ms to 4h")
64
+ .min(MIN_REVIEW_TAB_DWELL_MS, "must be an integer from 250ms to 4h")
65
+ .max(MAX_REVIEW_TAB_DWELL_MS, "must be an integer from 250ms to 4h"),
66
+ app_session_id: z
67
+ .string()
68
+ .regex(APP_SESSION_ID_PATTERN, "must be a valid session id"),
69
+ document: z.unknown().optional(),
70
+ })
71
+ .transform((event) => ({
72
+ tab: event.tab,
73
+ reason: event.reason,
74
+ durationMs: event.duration_ms,
75
+ appSessionId: event.app_session_id,
76
+ }));
77
+
78
+ export function requestJsonErrorStatus(cause: unknown): number {
79
+ return cause instanceof HttpJsonError ? cause.statusCode : 400;
80
+ }
81
+
82
+ export function parseReviewTabTelemetryInput(
83
+ value: JsonValue,
84
+ ): ReviewTabTelemetryEvent {
85
+ return parseZod(
86
+ ReviewTabTelemetryInputSchema,
87
+ value,
88
+ "Review tab telemetry event",
89
+ );
90
+ }
@@ -0,0 +1,73 @@
1
+ import type { PostHogCaptureProperties } from "@review/posthog-capture-client";
2
+ import type { ReviewApiHooks } from "@review/review-api/http.js";
3
+ import type {
4
+ ReviewSessionAgent,
5
+ ReviewTelemetry,
6
+ } from "@review/review-telemetry";
7
+
8
+ /**
9
+ * Review created, published and revoked, and agent authoring completed, as
10
+ * server events. Authoring is complete at the first publish of a review an
11
+ * agent created through `whiteboard api` or `whiteboard mcp`, timed from the review's
12
+ * creation. Which reviews an agent created is known only to this process, so
13
+ * a server restart between create and publish loses that one completion.
14
+ * Sign-in reports its funnel, and a success calls `onLoggedIn`.
15
+ */
16
+ export function reviewLifecycleTelemetry(
17
+ telemetry: Pick<ReviewTelemetry, "captureEvent">,
18
+ firstCreatedAt: (reviewId: string) => string | undefined,
19
+ onLoggedIn: () => Promise<void>,
20
+ now: () => number = Date.now,
21
+ ): ReviewApiHooks {
22
+ const reported = new Set<string>();
23
+ const authoring = new Map<string, ReviewSessionAgent | undefined>();
24
+
25
+ const capture = (
26
+ event: string,
27
+ properties?: PostHogCaptureProperties,
28
+ reviewUuid?: string,
29
+ ) =>
30
+ void telemetry.captureEvent(
31
+ event,
32
+ properties,
33
+ reviewUuid ? { reviewUuid } : undefined,
34
+ );
35
+
36
+ return {
37
+ onReviewCreated: ({ reviewId, kind, blocks, via, agentKind }) => {
38
+ if (reported.has(reviewId)) return;
39
+ reported.add(reviewId);
40
+
41
+ if (via === "api" || via === "mcp") authoring.set(reviewId, agentKind);
42
+ const properties: PostHogCaptureProperties = { kind, blocks, via };
43
+
44
+ if (agentKind) properties.agent_kind = agentKind;
45
+ capture("review_review_created", properties, reviewId);
46
+ },
47
+ sharing: {
48
+ onPublished: ({ reviewId, version }) => {
49
+ capture("review_review_published", { version }, reviewId);
50
+
51
+ if (!authoring.has(reviewId)) return;
52
+ const agentKind = authoring.get(reviewId);
53
+ authoring.delete(reviewId);
54
+ const createdAt = Date.parse(firstCreatedAt(reviewId) ?? "");
55
+
56
+ if (Number.isNaN(createdAt)) return;
57
+
58
+ const properties: PostHogCaptureProperties = {
59
+ duration_ms: Math.max(0, now() - createdAt),
60
+ };
61
+
62
+ if (agentKind) properties.agent_kind = agentKind;
63
+ capture("review_authoring_completed", properties, reviewId);
64
+ },
65
+ onRevoked: () => capture("review_review_revoked"),
66
+ onLogin: (outcome, reason) => {
67
+ capture(`review_login_${outcome}`, reason ? { reason } : {});
68
+
69
+ if (outcome === "succeeded") void onLoggedIn();
70
+ },
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,46 @@
1
+ const OPEN_TIMEOUT_MS = 30_000;
2
+
3
+ export interface ReviewOpenContext {
4
+ reviewUuid: string;
5
+ presentationSessionId: string;
6
+ }
7
+
8
+ /**
9
+ * "Opened but never presented" as an explicit event: one timer per
10
+ * presentation from its session start, cleared when the canvas reports it
11
+ * presented or the session ends. Keyed by presentation because a resume starts
12
+ * a new session for the same review.
13
+ */
14
+ export class ReviewOpenWatchdog {
15
+ private readonly pending = new Map<string, ReturnType<typeof setTimeout>>();
16
+
17
+ constructor(
18
+ private readonly options: {
19
+ onTimeout: (context: ReviewOpenContext, elapsedMs: number) => void;
20
+ },
21
+ ) {}
22
+
23
+ started(context: ReviewOpenContext): void {
24
+ const id = context.presentationSessionId;
25
+ this.presented(id);
26
+ const startedAt = Date.now();
27
+
28
+ const timer = setTimeout(() => {
29
+ this.pending.delete(id);
30
+ this.options.onTimeout(context, Date.now() - startedAt);
31
+ }, OPEN_TIMEOUT_MS);
32
+
33
+ timer.unref?.();
34
+ this.pending.set(id, timer);
35
+ }
36
+
37
+ presented(presentationSessionId: string): void {
38
+ clearTimeout(this.pending.get(presentationSessionId));
39
+ this.pending.delete(presentationSessionId);
40
+ }
41
+
42
+ dispose(): void {
43
+ for (const timer of this.pending.values()) clearTimeout(timer);
44
+ this.pending.clear();
45
+ }
46
+ }
@@ -0,0 +1,290 @@
1
+ import type { JsonObject } from "@dev.fast/json";
2
+ import type {
3
+ ReviewServerHealth,
4
+ ReviewServerHealthWithToken,
5
+ } from "@dev.fast/review-protocol";
6
+ import { traceMachineEnabled } from "@dev.fast/trace-core";
7
+ import { detectAskAgents, launchAskAgent } from "@review/ask/agents.js";
8
+ import { AskThreads, type AskTools } from "@review/ask/threads.js";
9
+ import {
10
+ readBuildCommit,
11
+ readReviewPackageVersion,
12
+ } from "@review/package-paths.js";
13
+ import { ReviewInputError } from "@review/review-api/document.js";
14
+ import {
15
+ type AuthoringCapabilities,
16
+ type ReviewApiHooks,
17
+ createReviewApi,
18
+ } from "@review/review-api/http.js";
19
+ import type { LocalReviewData } from "@review/review-api/local-data.js";
20
+ import type { ReviewStore } from "@review/review-api/store.js";
21
+ import { mountSharingPublisher } from "@review/sharing/host.js";
22
+ import type { SharedReviewStore } from "@review/sharing/import.js";
23
+ import { type Context, Hono } from "hono";
24
+ import { streamSSE } from "hono/streaming";
25
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
26
+ import { z } from "zod";
27
+
28
+ import type { ReviewDesktopVerbRelay } from "./global-verb-relay";
29
+ import {
30
+ type ReviewHonoEnv,
31
+ applyCorsHeaders,
32
+ corsPreflightResponse,
33
+ isAuthorizedRequest,
34
+ jsonResponse,
35
+ readBoundedRequestJson,
36
+ } from "./hono-http";
37
+ import { HttpJsonError, ReviewServerError } from "./http-json";
38
+
39
+ const version = readReviewPackageVersion(import.meta.url);
40
+
41
+ const commit = readBuildCommit(import.meta.url);
42
+
43
+ /**
44
+ * What every review server shares: CORS, an open /health, token auth, and
45
+ * the /control relay a Desktop attaches to, with errors answered as JSON.
46
+ * Callers add their routes after.
47
+ */
48
+ export function createReviewServerApp(input: {
49
+ token: string;
50
+ instanceId: string;
51
+ /** The review store's `serverId()`. */
52
+ serverId: string;
53
+ relay: ReviewDesktopVerbRelay;
54
+ }): Hono<ReviewHonoEnv> {
55
+ const app = new Hono<ReviewHonoEnv>();
56
+ app.use("*", async (context, next) => {
57
+ await next();
58
+ applyCorsHeaders(context.req.raw, context.res);
59
+ });
60
+ app.options("*", (context) => corsPreflightResponse(context.req.raw));
61
+ // Open to any caller, but the stable ids only to one holding the token.
62
+ app.get("/health", (context) => {
63
+ const health: ReviewServerHealth = {
64
+ ok: true,
65
+ instanceId: input.instanceId,
66
+ desktopAttached: input.relay.attached,
67
+ version,
68
+ };
69
+
70
+ return serverJson(
71
+ 200,
72
+ isAuthorizedRequest(context.req.raw, input.token)
73
+ ? ({
74
+ ...health,
75
+ serverId: input.serverId,
76
+ serverPid: process.pid,
77
+ commit,
78
+ } satisfies ReviewServerHealthWithToken)
79
+ : health,
80
+ );
81
+ });
82
+ app.use("*", async (context, next) => {
83
+ if (!isAuthorizedRequest(context.req.raw, input.token)) {
84
+ return serverJson(401, { ok: false, error: "Unauthorized" });
85
+ }
86
+
87
+ await next();
88
+ });
89
+ app.get("/control", (context) => openControlEvents(context, input.relay));
90
+ app.post("/control/result", async (context) => {
91
+ const accepted = input.relay.acceptResult(
92
+ await readBoundedRequestJson(context.req.raw),
93
+ );
94
+
95
+ return serverJson(accepted ? 200 : 404, { ok: accepted });
96
+ });
97
+ app.notFound(() => serverJson(404, { ok: false, error: "Not found." }));
98
+ app.onError((error) => {
99
+ const serverError = error instanceof ReviewServerError ? error : undefined;
100
+
101
+ const message = toError(error).message;
102
+
103
+ return serverJson(
104
+ serverError?.statusCode ?? httpJsonStatus(error),
105
+ serverError?.code
106
+ ? { ok: false, code: serverError.code, error: message }
107
+ : { ok: false, error: message },
108
+ );
109
+ });
110
+
111
+ return app;
112
+ }
113
+
114
+ export interface WhiteboardCoreInput {
115
+ profile: {
116
+ store: ReviewStore;
117
+ data: LocalReviewData;
118
+ shared?: SharedReviewStore;
119
+ };
120
+ relay: ReviewDesktopVerbRelay;
121
+ token: string;
122
+ instanceId: string;
123
+ softwareMapEnabled?: boolean;
124
+ scratchpad: () => boolean;
125
+ status: () => JsonObject;
126
+ hooks?: ReviewApiHooks;
127
+ ask?: { tools: AskTools };
128
+ }
129
+
130
+ export function createWhiteboardCore(input: WhiteboardCoreInput) {
131
+ const { store, data, shared } = input.profile;
132
+
133
+ const app = createReviewServerApp({
134
+ token: input.token,
135
+ instanceId: input.instanceId,
136
+ serverId: store.serverId(),
137
+ relay: input.relay,
138
+ });
139
+
140
+ const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
141
+
142
+ const askThreads =
143
+ input.ask && new AskThreads(launchAskAgent, input.ask.tools);
144
+
145
+ const api = createReviewApi(
146
+ store,
147
+ data,
148
+ callbacks.open,
149
+ shared,
150
+ callbacks.capabilities,
151
+ input.scratchpad,
152
+ () => traceMachineEnabled(),
153
+ input.status,
154
+ input.hooks,
155
+ askThreads && { threads: askThreads, agents: () => detectAskAgents() },
156
+ );
157
+
158
+ // A shared store mounts the publisher with the rest of sharing.
159
+ if (!shared) mountSharingPublisher(api, store, data);
160
+
161
+ return { app, api, close: () => askThreads?.closeAll() };
162
+ }
163
+
164
+ /** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
165
+ export function relayReviewCallbacks(
166
+ relay: ReviewDesktopVerbRelay,
167
+ softwareMapEnabled = false,
168
+ ) {
169
+ return {
170
+ async open(review: {
171
+ reviewId: string;
172
+ title: string;
173
+ }): Promise<{ softwareMapEnabled: boolean }> {
174
+ const result = await relay.dispatch({
175
+ name: "openApiReview",
176
+ args: review,
177
+ });
178
+
179
+ if (!result.ok) throw new ReviewInputError(result.error, 409);
180
+
181
+ return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
182
+ },
183
+ async capabilities(): Promise<
184
+ Omit<AuthoringCapabilities, "scratchpadEnabled">
185
+ > {
186
+ if (!relay.attached)
187
+ return { desktopAvailable: false, softwareMapEnabled };
188
+
189
+ const result = await relay.dispatch({
190
+ name: "authoringCapabilities",
191
+ args: {},
192
+ });
193
+
194
+ if (!result.ok) throw new ReviewInputError(result.error, 409);
195
+
196
+ return {
197
+ desktopAvailable: true,
198
+ ...z.object({ softwareMapEnabled: z.boolean() }).parse(result.result),
199
+ };
200
+ },
201
+ };
202
+ }
203
+
204
+ function openControlEvents(
205
+ context: Context<ReviewHonoEnv>,
206
+ relay: ReviewDesktopVerbRelay,
207
+ ): Response {
208
+ let attached = false;
209
+
210
+ const response = streamSSE(context, async (output) => {
211
+ let finish!: () => void;
212
+
213
+ const disconnected = new Promise<void>((resolve) => {
214
+ finish = resolve;
215
+ });
216
+
217
+ const abort = new AbortController();
218
+
219
+ let pending: Promise<void> = output
220
+ .write(": attached\n\n")
221
+ .then(() => undefined);
222
+
223
+ const writer = {
224
+ signal: abort.signal,
225
+ write(frame: string) {
226
+ pending = pending.then(async () => {
227
+ await output.write(frame);
228
+ });
229
+ },
230
+ close() {
231
+ finish();
232
+ void output.close();
233
+ },
234
+ };
235
+
236
+ output.onAbort(() => {
237
+ abort.abort();
238
+ finish();
239
+ });
240
+ attached = relay.attach(writer);
241
+
242
+ if (!attached) {
243
+ finish();
244
+
245
+ return;
246
+ }
247
+
248
+ try {
249
+ await disconnected;
250
+ await pending;
251
+ } finally {
252
+ abort.abort();
253
+ }
254
+ });
255
+
256
+ if (!attached) {
257
+ void response.body?.cancel();
258
+
259
+ // A new Response: one built on the stream's context would keep its
260
+ // chunked framing beside a Content-Length.
261
+ return serverJson(409, {
262
+ ok: false,
263
+ error: "This server has no room for another Whiteboard Desktop.",
264
+ });
265
+ }
266
+
267
+ // Never reused: a kept-alive socket would hold shutdown open after the
268
+ // relay ends the stream.
269
+ response.headers.set("connection", "close");
270
+ response.headers.set("cache-control", "no-cache, no-transform");
271
+ response.headers.set("content-type", "text/event-stream; charset=utf-8");
272
+
273
+ return response;
274
+ }
275
+
276
+ export function serverJson<T>(status: number, body: T): Response {
277
+ // SAFETY: callers pass 2xx/4xx/5xx codes (literals, ReviewServerError and
278
+ // HttpJsonError statusCode); none is a bodyless 1xx/204/205/304 status.
279
+ return jsonResponse(body, status as ContentfulStatusCode, {
280
+ cacheControl: "no-store",
281
+ });
282
+ }
283
+
284
+ function httpJsonStatus(cause: unknown): number {
285
+ return cause instanceof HttpJsonError ? cause.statusCode : 400;
286
+ }
287
+
288
+ function toError(cause: unknown): Error {
289
+ return cause instanceof Error ? cause : new Error(String(cause));
290
+ }