@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,1877 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { type JsonObject, isJsonObject } from "@dev.fast/json";
4
+ import {
5
+ REVIEW_CLIENT_HEADER,
6
+ REVIEW_CLIENT_REMOTE,
7
+ type ReviewStructuralDiffEvent,
8
+ } from "@dev.fast/review-protocol";
9
+ import { errorMessage } from "@dev.fast/trace-core";
10
+ import {
11
+ type AgentSelection,
12
+ AgentSelectionSchema,
13
+ selectionMarkdown,
14
+ } from "@review/agent-selection.js";
15
+ import type { AskAgentStatus } from "@review/ask/agents.js";
16
+ import { checkoutFiles, mentionableFiles } from "@review/ask/checkout-files.js";
17
+ import { parseFileRef, resolveFileRefs } from "@review/ask/file-refs.js";
18
+ import {
19
+ type AskAgentId,
20
+ askAgentIds,
21
+ askChoiceKinds,
22
+ askPicksSchema,
23
+ askQuestionSchema,
24
+ } from "@review/ask/thread-state.js";
25
+ import type { AskThreads } from "@review/ask/threads.js";
26
+ import { watchAskThread } from "@review/ask/watch.js";
27
+ import { fuzzyRank } from "@review/fuzzy-match.js";
28
+ import { resolveReviewStackLayers } from "@review/review-stack.js";
29
+ import { readBoundedRequestJson } from "@review/server/hono-http.js";
30
+ import { HttpJsonError } from "@review/server/http-json.js";
31
+ import {
32
+ type SharingHostEvents,
33
+ mountSharingHost,
34
+ } from "@review/sharing/host.js";
35
+ import type { SharedReviewStore } from "@review/sharing/import.js";
36
+ import { SharedReviewData } from "@review/sharing/routes.js";
37
+ import type { ReviewSessionAgent } from "@review/ui-telemetry-events.js";
38
+ import { scopedCoverage } from "@review/viewed-coverage.js";
39
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
40
+ import { z } from "zod";
41
+
42
+ import { anchorQuotes } from "./anchor-quotes.js";
43
+ import { authoringTools } from "./authoring-tools.js";
44
+ import { documentText } from "./document-text.js";
45
+ import { ReviewInputError } from "./document.js";
46
+ import {
47
+ instructionsQuerySchema,
48
+ renderInstructions,
49
+ scratchpadAvailable,
50
+ } from "./instructions.js";
51
+ import type { LocalReviewData } from "./local-data.js";
52
+ import {
53
+ inspectQuerySchema,
54
+ queryAnchor,
55
+ readQuerySchemas,
56
+ } from "./read-schemas.js";
57
+ import {
58
+ type ReviewRequestVia,
59
+ reviewRequestOrigin,
60
+ } from "./request-origin.js";
61
+ import {
62
+ type UncategorizedReport,
63
+ coverageModeSchema,
64
+ lensReport,
65
+ progressUpdateSchema,
66
+ reviewProgress,
67
+ uncategorizedReport,
68
+ } from "./review-progress.js";
69
+ import {
70
+ type ReviewStore,
71
+ SCRATCHPAD_ID,
72
+ type Snapshot,
73
+ commandSchema,
74
+ inspectSnapshot,
75
+ } from "./store.js";
76
+ import { listPinnedTraces, readStoredTrace } from "./traces.js";
77
+
78
+ export interface AskHost {
79
+ threads: AskThreads;
80
+ agents: () => Promise<AskAgentStatus[]>;
81
+ }
82
+
83
+ const askStartSchema = z.strictObject({
84
+ agent: z.enum(askAgentIds),
85
+ question: askQuestionSchema,
86
+ selection: AgentSelectionSchema,
87
+ picks: askPicksSchema.optional(),
88
+ bypass: z.boolean().optional(),
89
+ });
90
+
91
+ const askPermitSchema = z.strictObject({ bypass: z.boolean() });
92
+
93
+ const askOpenSchema = z.strictObject({ picks: askPicksSchema.optional() });
94
+
95
+ const askChoiceSchema = z.strictObject({
96
+ kind: z.enum(askChoiceKinds),
97
+ value: z.string().min(1).max(200),
98
+ });
99
+
100
+ const askFilesSchema = z.strictObject({
101
+ paths: z.array(z.string().min(1).max(400)).max(100),
102
+ });
103
+
104
+ const askFollowUpSchema = z.strictObject({ question: askQuestionSchema });
105
+
106
+ /** A question with its images: four of up to 5 MB each, base64. */
107
+ const ASK_REQUEST_MAX_BYTES = 30 * 1024 * 1024;
108
+
109
+ const askMentionsSchema = z.object({
110
+ query: z.string().max(400).default(""),
111
+ /** A conversation's checkout, which can be an earlier version's. */
112
+ thread: z.string().optional(),
113
+ });
114
+
115
+ const askOfferQuerySchema = z.object({
116
+ /** The model picked for a question not yet asked. */
117
+ model: askPicksSchema.shape.model,
118
+ });
119
+
120
+ /** How many files a mention picker shows. */
121
+ const MENTION_LIMIT = 20;
122
+
123
+ const askDecisionSchema = z.strictObject({
124
+ permissionId: z.string().min(1),
125
+ optionId: z.string().min(1),
126
+ });
127
+
128
+ export interface AuthoringCapabilities {
129
+ desktopAvailable: boolean;
130
+ softwareMapEnabled: boolean;
131
+ /** Off, the host neither makes nor lists the scratchpad, and refuses its id. */
132
+ scratchpadEnabled: boolean;
133
+ }
134
+
135
+ const SCRATCHPAD_DISABLED =
136
+ "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
137
+
138
+ /**
139
+ * What the host reports about reviews, for telemetry. `onReviewCreated` fires
140
+ * again for a replayed create command; consumers dedupe by review id.
141
+ */
142
+ export interface ReviewApiHooks {
143
+ onReviewCreated?: (event: {
144
+ reviewId: string;
145
+ kind: "review" | "scratchpad";
146
+ blocks: number;
147
+ via: ReviewRequestVia;
148
+ agentKind?: ReviewSessionAgent;
149
+ }) => void;
150
+ sharing?: SharingHostEvents;
151
+ }
152
+
153
+ /** A gateway forwarding from another machine; it gets no local paths. */
154
+ const remoteCaller = (context: Context) =>
155
+ context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
156
+
157
+ /** Both hosts mount this behind their token authentication. */
158
+ export function createReviewApi(
159
+ store: ReviewStore,
160
+ data?: LocalReviewData,
161
+ open?: (review: {
162
+ reviewId: string;
163
+ title: string;
164
+ }) => Promise<{ softwareMapEnabled: boolean }>,
165
+ shared?: SharedReviewStore,
166
+ capabilities: () =>
167
+ | Omit<AuthoringCapabilities, "scratchpadEnabled">
168
+ | Promise<Omit<AuthoringCapabilities, "scratchpadEnabled">> = () => ({
169
+ desktopAvailable: Boolean(open),
170
+ softwareMapEnabled: false,
171
+ }),
172
+ // Synchronous because the catalog is read inside watch callbacks. The host
173
+ // keeps it current from its preferences file.
174
+ scratchpadEnabled: () => boolean = () => false,
175
+ // Read per request: capture can change from outside this server.
176
+ traceEnabled: () => Promise<boolean> = async () => false,
177
+ /** Which server this is, for whiteboard_status. */
178
+ status: () => JsonObject = () => ({}),
179
+ hooks: ReviewApiHooks = {},
180
+ /** Desktop's Ask: local agents answering questions about a selection. */
181
+ ask?: AskHost,
182
+ ) {
183
+ const app = new Hono();
184
+ app.onError((error, context) => {
185
+ if (error instanceof HttpJsonError)
186
+ return context.json({ error: error.message }, error.statusCode);
187
+
188
+ if (error instanceof ReviewInputError)
189
+ return context.json({ error: error.message }, error.status);
190
+
191
+ // A readable message for agents and the canvas; issues stay for programs.
192
+ if (error instanceof z.ZodError)
193
+ return context.json(
194
+ { error: z.prettifyError(error), issues: error.issues },
195
+ 400,
196
+ );
197
+
198
+ // Provider failures may contain local paths/subprocess output: the server
199
+ // log gets the cause, the response only its kind. Desktop routes this
200
+ // process's stderr to its main log.
201
+ console.error(
202
+ `[Review API] ${context.req.method} ${context.req.path} failed:`,
203
+ error,
204
+ );
205
+
206
+ return context.json(
207
+ {
208
+ error: `Whiteboard operation failed (${failureKind(error)}). The server logged the cause; Whiteboard Desktop writes it to main.log in its logs folder.`,
209
+ },
210
+ 500,
211
+ );
212
+ });
213
+
214
+ if (data)
215
+ app.use("*", async (context, next) => {
216
+ if (context.req.method === "GET" && !context.req.query("version"))
217
+ await store.refreshWorktrees();
218
+ await next();
219
+ });
220
+
221
+ const sharedData = shared ? new SharedReviewData(shared) : undefined;
222
+ const isShared = (id: string) => id.startsWith("shared-");
223
+
224
+ const sharedCommandSchema = z.object({
225
+ operation: z.object({ reviewId: z.string().optional() }),
226
+ });
227
+
228
+ // The host that can show the scratchpad keeps it: Desktop, while the
229
+ // preference is on. Headless servers never make one, and a pad made earlier
230
+ // stays in the store while it is off.
231
+ const ensureScratchpad = async (id?: string) => {
232
+ if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID))
233
+ await store.ensureScratchpad();
234
+ };
235
+
236
+ const refuseDisabledScratchpad = (id?: string) => {
237
+ if (id === SCRATCHPAD_ID && !scratchpadEnabled())
238
+ throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
239
+ };
240
+
241
+ const sharedGuard: MiddlewareHandler = async (context, next) => {
242
+ refuseDisabledScratchpad(context.req.param("id"));
243
+ await ensureScratchpad(context.req.param("id"));
244
+
245
+ const id = context.req.param("id");
246
+
247
+ if (!id || !isShared(id)) return next();
248
+
249
+ const query = readQuerySchemas.get.parse({
250
+ version: context.req.query("version"),
251
+ });
252
+
253
+ readReview(id, query.version);
254
+
255
+ if (
256
+ context.req.method !== "GET" &&
257
+ !/\/(open|source|copy-context|environment)$/.test(context.req.path) &&
258
+ !/\/workspaces\/[^/]+\/retry$/.test(context.req.path)
259
+ )
260
+ throw new ReviewInputError("Shared reviews are read-only.", 409);
261
+
262
+ return next();
263
+ };
264
+
265
+ app.use("/:id", sharedGuard);
266
+ app.use("/:id/*", sharedGuard);
267
+
268
+ if (shared && data) {
269
+ shared.connect(store, data);
270
+ mountSharingHost(app, store, data, shared, hooks.sharing);
271
+ }
272
+
273
+ const readReview = (id: string, version?: number): Snapshot => {
274
+ if (!id.startsWith("shared-")) return store.read(id, version);
275
+ const snapshot = shared?.get(id).snapshot;
276
+
277
+ if (!snapshot || (version !== undefined && version !== snapshot.version))
278
+ throw new ReviewInputError("Shared review version is unavailable.", 404);
279
+
280
+ return snapshot;
281
+ };
282
+
283
+ const catalog = (mode: "structural" | "textual" = "structural") => {
284
+ const local = store.list(mode);
285
+
286
+ return [
287
+ ...(scratchpadEnabled()
288
+ ? local
289
+ : local.filter((summary) => summary.kind !== "scratchpad")),
290
+ ...(shared?.list(mode) ?? []),
291
+ ];
292
+ };
293
+
294
+ app.get("/", async (context) => {
295
+ await ensureScratchpad();
296
+
297
+ return context.json(
298
+ catalog(coverageModeSchema.parse(context.req.query("mode"))),
299
+ );
300
+ });
301
+
302
+ // Server-owned state only: asking the Desktop canvas would let a stalled
303
+ // renderer block tool listing and the first instructions call.
304
+ const instructionContext = async () => ({
305
+ desktopAvailable: Boolean(open),
306
+ scratchpadEnabled: scratchpadEnabled(),
307
+ traceEnabled: await traceEnabled(),
308
+ });
309
+
310
+ app.get("/authoring", async (context) => {
311
+ const instructions = await instructionContext();
312
+
313
+ return context.json(
314
+ authoringTools(
315
+ scratchpadAvailable(instructions),
316
+ instructions.traceEnabled,
317
+ ),
318
+ );
319
+ });
320
+ app.get("/instructions", async (context) => {
321
+ const { topic } = instructionsQuerySchema.parse(context.req.query());
322
+
323
+ return context.json(
324
+ await renderInstructions(topic, await instructionContext()),
325
+ );
326
+ });
327
+ app.get("/:id/progress", async (context) => {
328
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
329
+
330
+ const query = readQuerySchemas.get
331
+ .pick({ version: true })
332
+ .extend({
333
+ mode: coverageModeSchema,
334
+ wait: z.enum(["false", "true"]).default("true"),
335
+ })
336
+ .parse(context.req.query());
337
+
338
+ const snapshot = readReview(context.req.param("id"), query.version);
339
+
340
+ const documentPins =
341
+ query.wait === "false" && snapshot.pins
342
+ ? (await data.resolveSource(snapshot)).pins
343
+ : undefined;
344
+
345
+ if (documentPins) {
346
+ const state = data.coverageSnapshot(
347
+ snapshot.reviewId,
348
+ documentPins,
349
+ query.mode,
350
+ );
351
+
352
+ if (state.pending)
353
+ return context.json(
354
+ await reviewProgress(
355
+ store,
356
+ data,
357
+ snapshot,
358
+ context.req.raw.signal,
359
+ query.mode,
360
+ state.comparison,
361
+ ),
362
+ 202,
363
+ );
364
+ }
365
+
366
+ return context.json(
367
+ await reviewProgress(
368
+ store,
369
+ data,
370
+ snapshot,
371
+ context.req.raw.signal,
372
+ query.mode,
373
+ ),
374
+ );
375
+ });
376
+ app.post("/:id/progress", async (context) => {
377
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
378
+
379
+ const input = progressUpdateSchema.parse(
380
+ await readBoundedRequestJson(context.req.raw),
381
+ );
382
+
383
+ const id = context.req.param("id");
384
+
385
+ const snapshot = store.read(id);
386
+
387
+ const progress = await reviewProgress(
388
+ store,
389
+ data,
390
+ snapshot,
391
+ context.req.raw.signal,
392
+ input.mode,
393
+ );
394
+
395
+ const files = input.files.map((update) => {
396
+ const file = progress.files.find((file) => file.path === update.path);
397
+
398
+ if (!file || file.fingerprint !== update.fingerprint)
399
+ throw new ReviewInputError(
400
+ "This file changed. Reload before marking it viewed.",
401
+ 409,
402
+ );
403
+
404
+ return {
405
+ path: file.path,
406
+ fingerprint: file.fingerprint,
407
+ scope: scopedCoverage(file, update.sources),
408
+ };
409
+ });
410
+
411
+ if (store.read(id).version !== snapshot.version)
412
+ throw new ReviewInputError(
413
+ "Review changed during this update. Try again.",
414
+ 409,
415
+ );
416
+ store.updateViewedCoverage(id, files, input.viewed);
417
+
418
+ return context.json(
419
+ await reviewProgress(
420
+ store,
421
+ data,
422
+ store.read(id, input.version),
423
+ context.req.raw.signal,
424
+ input.mode,
425
+ ),
426
+ );
427
+ });
428
+ // A lens author's cheap read: the lenses as authored, what each resolves
429
+ // to, and the changed lines no lens selects yet.
430
+ app.get("/:id/lenses", async (context) => {
431
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
432
+
433
+ const snapshot = readReview(context.req.param("id"));
434
+
435
+ return context.json({
436
+ version: snapshot.version,
437
+ ...lensReport(
438
+ snapshot.lenses ?? [],
439
+ await reviewProgress(store, data, snapshot, context.req.raw.signal),
440
+ ),
441
+ });
442
+ });
443
+ app.get("/status", async (context) =>
444
+ context.json({
445
+ ...status(),
446
+ desktopAvailable: (await capabilities()).desktopAvailable,
447
+ }),
448
+ );
449
+
450
+ app.get("/capabilities", async (context) =>
451
+ context.json({
452
+ ...(await capabilities()),
453
+ scratchpadEnabled: scratchpadEnabled(),
454
+ }),
455
+ );
456
+
457
+ app.get("/:id/activity", (context) => {
458
+ const id = context.req.param("id");
459
+ readReview(id);
460
+
461
+ return context.json(
462
+ isShared(id)
463
+ ? { workingCount: 0, expiresAt: null }
464
+ : store.activity.read(id),
465
+ );
466
+ });
467
+
468
+ // One route per agent tool; the path names the action.
469
+ for (const action of ["begin", "update", "end"] as const)
470
+ app.post(`/:id/activity/${action}`, async (context) => {
471
+ const input = await readBoundedRequestJson(context.req.raw);
472
+ const id = context.req.param("id");
473
+ store.assertExists(id);
474
+
475
+ return context.json(
476
+ store.activity.update(
477
+ id,
478
+ isJsonObject(input) ? { ...input, action } : input,
479
+ ),
480
+ );
481
+ });
482
+ app.get("/watch", async (context) => {
483
+ const query = context.req.query("subscriptions");
484
+
485
+ if (query !== undefined) {
486
+ let input: unknown;
487
+
488
+ try {
489
+ input = JSON.parse(query);
490
+ } catch {
491
+ throw new ReviewInputError("Invalid subscriptions.");
492
+ }
493
+
494
+ const subscriptions = z
495
+ .array(
496
+ z.strictObject({
497
+ reviewId: z.string().min(1).nullable(),
498
+ mode: coverageModeSchema,
499
+ }),
500
+ )
501
+ .parse(input);
502
+
503
+ // Only entries whose review (or the catalog) changed are re-read and re-sent.
504
+ const dirty = new Set(subscriptions.keys());
505
+
506
+ const mark = (id: string | null) => {
507
+ let marked = false;
508
+
509
+ subscriptions.forEach((item, index) => {
510
+ if (item.reviewId === id) {
511
+ dirty.add(index);
512
+ marked = true;
513
+ }
514
+ });
515
+
516
+ return marked;
517
+ };
518
+
519
+ return watch(
520
+ () =>
521
+ subscriptions.map(({ reviewId, mode }, index) => {
522
+ if (!dirty.delete(index)) return null;
523
+
524
+ try {
525
+ return {
526
+ value:
527
+ reviewId === null
528
+ ? catalog(mode)
529
+ : {
530
+ ...readReview(reviewId),
531
+ activity: store.activity.read(reviewId),
532
+ coverageRevision: data?.coverageRevision ?? 0,
533
+ },
534
+ };
535
+ } catch (error) {
536
+ return {
537
+ error:
538
+ error instanceof ReviewInputError
539
+ ? error.message
540
+ : "Could not read review.",
541
+ };
542
+ }
543
+ }),
544
+ (notify) => {
545
+ const stopRefresh = store.watchWorktrees();
546
+
547
+ const stops = [
548
+ stopRefresh,
549
+ data?.subscribeCoverage(() => {
550
+ subscriptions.forEach((item, index) => {
551
+ if (item.reviewId !== null) dirty.add(index);
552
+ });
553
+
554
+ // Coverage never changes the catalog; don't send an all-null line.
555
+ if (dirty.size > 0) notify();
556
+ }) ?? (() => {}),
557
+ store.subscribe((result) => {
558
+ if (mark(result.reviewId)) notify();
559
+ }),
560
+ store.activity.subscribe((id) => {
561
+ if (mark(id)) notify();
562
+ }),
563
+ store.activity.subscribeWorking(() => {
564
+ if (mark(null)) notify();
565
+ }),
566
+ shared?.subscribe(() => {
567
+ if (mark(null)) notify();
568
+ }) ?? (() => {}),
569
+ store.subscribeCatalog(() => {
570
+ if (mark(null)) notify();
571
+ }),
572
+ ];
573
+
574
+ return () => stops.forEach((stop) => stop());
575
+ },
576
+ // A missing review is an {error} entry here, never a 404.
577
+ () => {},
578
+ );
579
+ }
580
+
581
+ await ensureScratchpad();
582
+
583
+ return watch(
584
+ () => catalog(coverageModeSchema.parse(context.req.query("mode"))),
585
+ (notify) => {
586
+ const local = store.subscribeCatalog(notify);
587
+ const activity = store.activity.subscribeWorking(notify);
588
+ const imported = shared?.subscribe(notify);
589
+
590
+ return () => {
591
+ local();
592
+ activity();
593
+ imported?.();
594
+ };
595
+ },
596
+ );
597
+ });
598
+
599
+ /** Show a review in Desktop and start preparing its pinned checkouts. */
600
+ const openReview = async (review: Snapshot) => {
601
+ if (!open) throw new ReviewInputError("The desktop is not connected.", 409);
602
+
603
+ const settings = await open({
604
+ reviewId: review.reviewId,
605
+ title: review.title,
606
+ });
607
+
608
+ let environmentIssues: { side?: string; message: string }[] | undefined;
609
+
610
+ try {
611
+ if (review.target?.kind === "commits" && review.pins)
612
+ void data?.workspaces
613
+ .open(review.reviewId, review.pins)
614
+ .catch(() => {});
615
+ environmentIssues = data?.currentEnvironmentIssues(review);
616
+ } catch (error) {
617
+ environmentIssues = [
618
+ {
619
+ message: `Could not check language checkouts: ${errorMessage(error)}. Recheck with review_environment.`,
620
+ },
621
+ ];
622
+ }
623
+
624
+ return {
625
+ ...settings,
626
+ environmentIssues: environmentIssues?.length
627
+ ? environmentIssues
628
+ : undefined,
629
+ };
630
+ };
631
+
632
+ /**
633
+ * A created or returned review is shown where Desktop can, unless the author asked
634
+ * not to. The review is already saved, so a failed open is reported, not thrown.
635
+ */
636
+ const openCreated = async (reviewId: string) => {
637
+ try {
638
+ if (!open || !(await capabilities()).desktopAvailable)
639
+ return { opened: false };
640
+
641
+ return { opened: true, ...(await openReview(store.read(reviewId))) };
642
+ } catch (error) {
643
+ return {
644
+ opened: false,
645
+ openError: `${errorMessage(error)} Retry with review_open.`,
646
+ };
647
+ }
648
+ };
649
+
650
+ app.post("/:id/open", async (context) => {
651
+ const id = context.req.param("id");
652
+
653
+ if (isShared(id)) await shared?.assertReady(id);
654
+
655
+ return context.json({ ok: true, ...(await openReview(readReview(id))) });
656
+ });
657
+ app.get("/:id/watch", (context) => {
658
+ const id = context.req.param("id");
659
+
660
+ // Activity changes every renewal; reload the document only when it changed.
661
+ let document: Snapshot | undefined;
662
+
663
+ return watch(
664
+ () => ({
665
+ ...(document ??= readReview(id)),
666
+ activity: isShared(id)
667
+ ? { workingCount: 0, expiresAt: null }
668
+ : store.activity.read(id),
669
+ }),
670
+ (notify) => {
671
+ const stopRefresh = store.watchWorktrees();
672
+
673
+ const stopDocument = store.subscribe((result) => {
674
+ if (result.reviewId === id) {
675
+ document = undefined;
676
+ notify();
677
+ }
678
+ });
679
+
680
+ const stopActivity = store.activity.subscribe((changed) => {
681
+ if (changed === id) notify();
682
+ });
683
+
684
+ return () => {
685
+ stopRefresh();
686
+ stopDocument();
687
+ stopActivity();
688
+ };
689
+ },
690
+ );
691
+ });
692
+
693
+ if (data) {
694
+ const traceQuery = readQuerySchemas.maps.extend({
695
+ storage: z.enum(["s3", "hosted"]).optional(),
696
+ trace: z.string().min(1).optional(),
697
+ });
698
+
699
+ // Traces are stored beside the review's own repository.
700
+ const tracePins = (id: string, version?: number) => {
701
+ const { pins } = readReview(id, version);
702
+
703
+ if (!pins)
704
+ throw new ReviewInputError(
705
+ "This document has no source pins of its own.",
706
+ 409,
707
+ );
708
+
709
+ return pins;
710
+ };
711
+
712
+ app.get("/:id/agent-traces", async (context) => {
713
+ const query = traceQuery.parse(context.req.query());
714
+ const pins = tracePins(context.req.param("id"), query.version);
715
+
716
+ return context.json(
717
+ await listPinnedTraces(
718
+ store.repositoryPath(pins.repositoryId),
719
+ pins,
720
+ query.storage,
721
+ ),
722
+ );
723
+ });
724
+ app.get("/:id/agent-traces/:sessionId", async (context) => {
725
+ const query = traceQuery.parse(context.req.query());
726
+ const pins = tracePins(context.req.param("id"), query.version);
727
+
728
+ const result = await readStoredTrace(
729
+ store.repositoryPath(pins.repositoryId),
730
+ context.req.param("sessionId"),
731
+ query.trace,
732
+ query.storage,
733
+ );
734
+
735
+ if (!result.ok)
736
+ return context.json({ ok: false, error: result.error }, result.status);
737
+
738
+ return context.json(result);
739
+ });
740
+ app.post("/:id/navigator", async (context) => {
741
+ if (remoteCaller(context))
742
+ throw new ReviewInputError(
743
+ "Source windows are not available for a review on another machine.",
744
+ 409,
745
+ );
746
+
747
+ const input = readQuerySchemas.file
748
+ .extend({
749
+ side: z.enum(["base", "head"]).default("head"),
750
+ file: z.string().min(1).optional(),
751
+ empty: z.literal("true").optional(),
752
+ })
753
+ .parse(context.req.query());
754
+
755
+ return context.json(
756
+ await data.navigatorWorkspace(
757
+ readReview(context.req.param("id"), input.version),
758
+ {
759
+ ...input,
760
+ empty: input.empty === "true",
761
+ anchor: queryAnchor(input),
762
+ },
763
+ ),
764
+ );
765
+ });
766
+ app.get("/:id/tree", async (context) => {
767
+ const input = readQuerySchemas.tree.parse(context.req.query());
768
+
769
+ const { pins } = await data.resolveSource(
770
+ readReview(context.req.param("id"), input.version),
771
+ input.commit,
772
+ queryAnchor(input),
773
+ );
774
+
775
+ return context.json(await data!.tree(pins, input.side, input.path));
776
+ });
777
+ app.get("/:id/maps/:resourceId", async (context) => {
778
+ const query = readQuerySchemas.maps.parse(context.req.query());
779
+ const id = context.req.param("id");
780
+
781
+ if (id && isShared(id))
782
+ return context.json(
783
+ sharedData!.map(
784
+ id,
785
+ z.string().parse(context.req.param("resourceId")),
786
+ ),
787
+ );
788
+
789
+ return context.json(
790
+ await data.map(
791
+ await data.sourcePins(readReview(id, query.version)),
792
+ z.string().parse(context.req.param("resourceId")),
793
+ ),
794
+ );
795
+ });
796
+ app.post("/repositories", async (context) => {
797
+ const input = z
798
+ .strictObject({ path: z.string().min(1) })
799
+ .parse(await readBoundedRequestJson(context.req.raw));
800
+
801
+ return context.json(await data!.register(input.path));
802
+ });
803
+ app.post("/resources", async (context) =>
804
+ context.json(
805
+ await data!.upload(
806
+ await readBoundedRequestJson(context.req.raw, 8 * 1024 * 1024),
807
+ ),
808
+ ),
809
+ );
810
+ app.get("/:id/resources/:resourceId", async (context) => {
811
+ const id = context.req.param("id");
812
+ const snapshot = readReview(id);
813
+
814
+ const resource =
815
+ id && isShared(id)
816
+ ? {
817
+ ...(await sharedData!.resource(
818
+ id,
819
+ z.string().parse(context.req.param("resourceId")),
820
+ )),
821
+ repositoryId: snapshot.pins?.repositoryId ?? "",
822
+ }
823
+ : store.resource(z.string().parse(context.req.param("resourceId")));
824
+
825
+ // A document with pins serves only its repository's resources.
826
+ if (snapshot.pins && resource.repositoryId !== snapshot.pins.repositoryId)
827
+ throw new ReviewInputError("Resource is outside this repository.", 404);
828
+
829
+ return new Response(Buffer.from(resource.data), {
830
+ headers: {
831
+ "content-type": resource.mimeType,
832
+ "x-content-type-options": "nosniff",
833
+ },
834
+ });
835
+ });
836
+ app.get("/:id/language-context", async (context) => {
837
+ const input = readQuerySchemas.maps
838
+ .extend({
839
+ side: z.enum(["base", "head"]).default("head"),
840
+ commit: z.string().optional(),
841
+ repositoryId: z.string().optional(),
842
+ head: z.string().optional(),
843
+ base: z.string().optional(),
844
+ })
845
+ .parse(context.req.query());
846
+
847
+ const snapshot = readReview(context.req.param("id"), input.version);
848
+
849
+ const environment = await data.languageEnvironment(
850
+ snapshot,
851
+ input.side,
852
+ input.commit,
853
+ false,
854
+ queryAnchor(input),
855
+ );
856
+
857
+ return context.json(
858
+ remoteCaller(context)
859
+ ? {
860
+ // A live checkout's identity names its path; keep only its equality.
861
+ identity: createHash("sha256")
862
+ .update(environment.identity)
863
+ .digest("hex"),
864
+ // An acquisition error can quote local paths.
865
+ ...(environment.issue && {
866
+ issue:
867
+ "The checkout for language features is not available on the remote machine.",
868
+ }),
869
+ }
870
+ : environment,
871
+ );
872
+ });
873
+ app.post("/:id/environment", async (context) => {
874
+ const input = z
875
+ .strictObject({ retry: z.boolean().optional() })
876
+ .parse(await readBoundedRequestJson(context.req.raw));
877
+
878
+ return context.json({
879
+ issues: await data.environmentIssues(
880
+ readReview(context.req.param("id")),
881
+ input.retry,
882
+ ),
883
+ });
884
+ });
885
+ app.post("/workspace-cleanup", async (context) => {
886
+ const input = z
887
+ .strictObject({ workspaceId: z.string().min(1).optional() })
888
+ .parse(await readBoundedRequestJson(context.req.raw));
889
+
890
+ if (input.workspaceId)
891
+ await data.workspaces.retryCleanup(input.workspaceId);
892
+
893
+ return context.json({ failures: data.workspaces.failures() });
894
+ });
895
+ app.get("/:id/workspaces", (context) => {
896
+ readReview(context.req.param("id"));
897
+
898
+ return context.json(data.workspaces.list(context.req.param("id")));
899
+ });
900
+ app.post("/:id/workspaces/:workspaceId/retry", async (context) => {
901
+ return context.json(
902
+ await data.workspaces.retry(
903
+ context.req.param("id"),
904
+ context.req.param("workspaceId"),
905
+ ),
906
+ );
907
+ });
908
+ app.get("/:id/file", async (context) => {
909
+ // Browsing can describe binaries; authoring reads still require text.
910
+ const input = readQuerySchemas.file
911
+ .extend({ binary: z.literal("describe").optional() })
912
+ .parse(context.req.query());
913
+
914
+ const id = context.req.param("id");
915
+
916
+ const anchor = queryAnchor(input);
917
+
918
+ const { snapshot, pins } = await data.resolveSource(
919
+ readReview(id, input.version),
920
+ input.commit,
921
+ anchor,
922
+ );
923
+
924
+ const file = await data.file(
925
+ pins,
926
+ input.side,
927
+ input.file,
928
+ input.binary === "describe",
929
+ );
930
+
931
+ if (file.text.includes("\0")) {
932
+ return context.json({
933
+ binary: true,
934
+ file: file.file,
935
+ side: file.side,
936
+ commit: file.commit,
937
+ });
938
+ }
939
+
940
+ const local =
941
+ !remoteCaller(context) &&
942
+ !input.commit &&
943
+ !anchor &&
944
+ input.side === "head" &&
945
+ snapshot.target?.kind === "worktree"
946
+ ? await data.liveFile(pins.repositoryId, input.file, file.text)
947
+ : undefined;
948
+
949
+ return context.json({ ...file, ...local });
950
+ });
951
+ app.get("/:id/structural-diff", async (context) => {
952
+ const input = readQuerySchemas.structuralDiff.parse(context.req.query());
953
+ const id = context.req.param("id");
954
+
955
+ const { pins } = await data.resolveSource(
956
+ readReview(id, input.version),
957
+ input.commit,
958
+ queryAnchor(input),
959
+ );
960
+
961
+ const abort = new AbortController();
962
+ const encoder = new TextEncoder();
963
+
964
+ const stream = new ReadableStream<Uint8Array>({
965
+ async start(controller) {
966
+ const send = (event: ReviewStructuralDiffEvent) => {
967
+ if (!abort.signal.aborted)
968
+ controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
969
+ };
970
+
971
+ try {
972
+ for await (const event of data.structuralChanges({
973
+ reviewId: id,
974
+ pins,
975
+ signal: AbortSignal.any([context.req.raw.signal, abort.signal]),
976
+ file: input.file,
977
+ }))
978
+ send(event);
979
+ } catch (error) {
980
+ send({
981
+ type: "error",
982
+ message: error instanceof Error ? error.message : String(error),
983
+ });
984
+ } finally {
985
+ if (!abort.signal.aborted) controller.close();
986
+ }
987
+ },
988
+ cancel() {
989
+ abort.abort();
990
+ },
991
+ });
992
+
993
+ return new Response(stream, {
994
+ headers: {
995
+ "content-type": "application/x-ndjson",
996
+ "cache-control": "no-store",
997
+ },
998
+ });
999
+ });
1000
+ app.get("/:id/diff", async (context) => {
1001
+ const input = readQuerySchemas.diff.parse(context.req.query());
1002
+
1003
+ const { pins } = await data.resolveSource(
1004
+ readReview(context.req.param("id"), input.version),
1005
+ input.commit,
1006
+ queryAnchor(input),
1007
+ );
1008
+
1009
+ return context.json(await data.changes(pins));
1010
+ });
1011
+ app.get("/:id/commits", async (context) => {
1012
+ const input = readQuerySchemas.commits.parse(context.req.query());
1013
+
1014
+ return context.json(
1015
+ await data.commits(
1016
+ (
1017
+ await data.resolveSource(
1018
+ readReview(context.req.param("id"), input.version),
1019
+ )
1020
+ ).pins,
1021
+ ),
1022
+ );
1023
+ });
1024
+ }
1025
+
1026
+ app.post("/:id/copy-context", async (context) => {
1027
+ const query = readQuerySchemas.get
1028
+ .pick({ version: true })
1029
+ .extend({ mode: coverageModeSchema })
1030
+ .parse(context.req.query());
1031
+
1032
+ const selection = AgentSelectionSchema.parse(
1033
+ await readBoundedRequestJson(context.req.raw),
1034
+ );
1035
+
1036
+ return context.json({
1037
+ text: await selectionContext(
1038
+ context.req.param("id"),
1039
+ selection,
1040
+ query.version,
1041
+ ),
1042
+ });
1043
+ });
1044
+
1045
+ /** The Markdown an agent gets for a selection, copied or asked about. */
1046
+ async function selectionContext(
1047
+ reviewId: string,
1048
+ selection: AgentSelection,
1049
+ version?: number,
1050
+ ) {
1051
+ if (selection.apiSource && selection.apiSource.reviewId !== reviewId)
1052
+ throw new ReviewInputError("Selection belongs to another review.");
1053
+
1054
+ const snapshot = readReview(
1055
+ reviewId,
1056
+ selection.apiSource?.version ?? version,
1057
+ );
1058
+
1059
+ const target = selection.target;
1060
+ let excerpt = "";
1061
+
1062
+ if (target.kind === "code" && !selection.selectedDiff) {
1063
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
1064
+
1065
+ const source = await data.quote(
1066
+ (
1067
+ await data.resolveSource(
1068
+ snapshot,
1069
+ selection.apiSource?.commit,
1070
+ selection.apiSource?.pins,
1071
+ )
1072
+ ).pins,
1073
+ {
1074
+ side: target.side,
1075
+ file: target.path,
1076
+ fromLine: target.startLine,
1077
+ toLine: target.endLine,
1078
+ },
1079
+ );
1080
+
1081
+ excerpt =
1082
+ `## ${target.side}: ${target.path}:${target.startLine}-${target.endLine} (${source.commit})\n` +
1083
+ source.text
1084
+ .split("\n")
1085
+ .map((line) => ` ${line}`)
1086
+ .join("\n");
1087
+ }
1088
+
1089
+ const diff = selection.selectedDiff;
1090
+
1091
+ const text = selectionMarkdown(
1092
+ selection,
1093
+ excerpt,
1094
+ diff
1095
+ ? { base: `a/${diff.oldPath}`, head: `b/${diff.newPath}` }
1096
+ : undefined,
1097
+ );
1098
+
1099
+ return [
1100
+ `Selected ${target.kind === "text" ? "text" : "code"} from Whiteboard: ${snapshot.title}`,
1101
+ `Session ID: ${snapshot.reviewId}`,
1102
+ `Version: ${snapshot.version}`,
1103
+ ...(selection.apiSource?.commit
1104
+ ? [`Selected commit: ${selection.apiSource.commit}`]
1105
+ : []),
1106
+ ...(selection.apiSource?.pins
1107
+ ? [
1108
+ `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
1109
+ ...(selection.apiSource.pins.base
1110
+ ? [`Selected base: ${selection.apiSource.pins.base}`]
1111
+ : []),
1112
+ `Selected head: ${selection.apiSource.pins.head}`,
1113
+ ]
1114
+ : []),
1115
+ ...(snapshot.pins
1116
+ ? [
1117
+ `Repository ID: ${snapshot.pins.repositoryId}`,
1118
+ `Session base: ${snapshot.pins.base}`,
1119
+ `Session head: ${snapshot.pins.head}`,
1120
+ ]
1121
+ : []),
1122
+ `Read this version with session_get({"sessionId":"${snapshot.reviewId}","version":${snapshot.version},"full":true}).`,
1123
+ "",
1124
+ text,
1125
+ "",
1126
+ "",
1127
+ ].join("\n");
1128
+ }
1129
+
1130
+ if (ask && data) {
1131
+ const readThread = (reviewId: string, threadId: string) => {
1132
+ const thread = ask.threads.get(threadId);
1133
+
1134
+ if (!thread || thread.reviewId !== reviewId)
1135
+ throw new ReviewInputError("This conversation has ended.", 404);
1136
+
1137
+ return thread;
1138
+ };
1139
+
1140
+ /** What an agent reads before a session's first question: the review,
1141
+ * the checkout, and the selection. */
1142
+ const askContext = async (
1143
+ agent: AskAgentId,
1144
+ snapshot: Snapshot,
1145
+ checkout: { head: string; live: boolean },
1146
+ selection: AgentSelection,
1147
+ version: number | undefined,
1148
+ ) => {
1149
+ const reach = await ask.threads.reach(agent);
1150
+ const { reviewId } = snapshot;
1151
+
1152
+ return [
1153
+ `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about a selection.`,
1154
+ checkout.live
1155
+ ? "Your working directory is the repository the review describes."
1156
+ : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
1157
+ "Answer the question.",
1158
+ "Name files by their path from the checkout root, with a line where it helps, as in `src/app.ts:42`; the reviewer can open them from your answer.",
1159
+ ...(reach?.kind === "mcp"
1160
+ ? [
1161
+ `The whiteboard MCP tools read and change this review: its sessionId is "${reviewId}". Read it with session_get. If the reviewer asks you to change the review, edit it with session_edit; do not write files to do it.`,
1162
+ ]
1163
+ : reach?.kind === "cli"
1164
+ ? [
1165
+ `Whiteboard's CLI reads and changes this review from your shell: its sessionId is "${reviewId}". Read it with \`${reach.command} api session_get '{"sessionId":"${reviewId}"}'\`. If the reviewer asks you to change the review, edit it with \`${reach.command} api session_edit '<json>'\`; do not write files to do it. \`${reach.command} api tools\` lists each tool's input.`,
1166
+ ]
1167
+ : []),
1168
+ "",
1169
+ await selectionContext(reviewId, selection, version),
1170
+ ].join("\n");
1171
+ };
1172
+
1173
+ // Each agent with the models and efforts it offered last; none until
1174
+ // it has run.
1175
+ app.get("/:id/ask/agents", async (context) =>
1176
+ context.json({ agents: await ask.agents() }),
1177
+ );
1178
+
1179
+ // What an agent offers: what it said last, else what a session
1180
+ // started in the review's checkout says. With another model it offers,
1181
+ // what it said last with that model, else what such a session says once
1182
+ // it has the model: the efforts on offer depend on it.
1183
+ app.get("/:id/ask/agents/:agent/offer", async (context) => {
1184
+ const agent = z.enum(askAgentIds).parse(context.req.param("agent"));
1185
+ const { model } = askOfferQuerySchema.parse(context.req.query());
1186
+ const stored = store.askHistory.offer(agent);
1187
+
1188
+ // An offer of nothing to choose was saved before the agent's
1189
+ // settings were known; the agent says again.
1190
+ const last =
1191
+ stored && Object.keys(stored.choices).length ? stored : undefined;
1192
+
1193
+ const models = last?.choices.model;
1194
+
1195
+ const another =
1196
+ model !== undefined &&
1197
+ model !== models?.current &&
1198
+ (!models || models.options.some((option) => option.value === model));
1199
+
1200
+ const known = another ? store.askHistory.offer(agent, model) : last;
1201
+
1202
+ if (known) return context.json({ offer: known });
1203
+
1204
+ const checkout = await data.agentCheckout(
1205
+ readReview(context.req.param("id")),
1206
+ );
1207
+
1208
+ const offer = await ask.threads.offered(
1209
+ agent,
1210
+ checkout.rootPath,
1211
+ another ? model : undefined,
1212
+ );
1213
+
1214
+ if (last) store.askHistory.saveModelOffer(agent, offer);
1215
+ else store.askHistory.saveOffer(agent, offer);
1216
+
1217
+ return context.json({ offer });
1218
+ });
1219
+
1220
+ // The checkout's files a mention could mean, best first.
1221
+ app.get("/:id/ask/mentions", async (context) => {
1222
+ const reviewId = context.req.param("id");
1223
+
1224
+ const { query, thread } = askMentionsSchema.parse(context.req.query());
1225
+
1226
+ const cwd = thread
1227
+ ? readThread(reviewId, thread).read().cwd
1228
+ : (await data.agentCheckout(readReview(reviewId))).rootPath;
1229
+
1230
+ const files = fuzzyRank(query, await mentionableFiles(cwd), (file) => [
1231
+ file,
1232
+ file.slice(file.lastIndexOf("/") + 1),
1233
+ ]);
1234
+
1235
+ return context.json({ paths: files.slice(0, MENTION_LIMIT) });
1236
+ });
1237
+
1238
+ app.post("/:id/ask", async (context) => {
1239
+ const reviewId = context.req.param("id");
1240
+
1241
+ const { version } = readQuerySchemas.get
1242
+ .pick({ version: true })
1243
+ .parse(context.req.query());
1244
+
1245
+ const input = askStartSchema.parse(
1246
+ await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES),
1247
+ );
1248
+
1249
+ const snapshot = readReview(reviewId, version);
1250
+ const checkout = await data.agentCheckout(snapshot);
1251
+ const target = input.selection.target;
1252
+ const id = crypto.randomUUID();
1253
+ const createdAt = new Date().toISOString();
1254
+
1255
+ const thread = ask.threads.open({
1256
+ id,
1257
+ reviewId,
1258
+ agent: input.agent,
1259
+ // Saved once the agent has a session to reopen; a later one
1260
+ // replaces a session the agent could not reopen.
1261
+ onSession: (sessionId) =>
1262
+ store.askHistory.get(id)
1263
+ ? store.askHistory.updateSession(id, sessionId)
1264
+ : store.askHistory.save({
1265
+ id,
1266
+ reviewId,
1267
+ agent: input.agent,
1268
+ sessionId,
1269
+ version: snapshot.version,
1270
+ head: checkout.head,
1271
+ cwd: checkout.rootPath,
1272
+ selection: input.selection,
1273
+ title: input.question.text.slice(0, 200),
1274
+ createdAt,
1275
+ updatedAt: createdAt,
1276
+ bypass: input.bypass,
1277
+ }),
1278
+ onTurn: () => store.askHistory.touch(id),
1279
+ onSave: (entries) => store.askHistory.saveEntries(id, entries),
1280
+ picks: input.picks,
1281
+ bypass: input.bypass,
1282
+ onBypass: (bypass) => store.askHistory.setBypass(id, bypass),
1283
+ onOffer: (offer) => store.askHistory.saveOffer(input.agent, offer),
1284
+ onTitle: (title) => store.askHistory.rename(id, title),
1285
+ cwd: checkout.rootPath,
1286
+ head: checkout.head,
1287
+ selection: {
1288
+ title: input.selection.title,
1289
+ quote: target.kind === "text" ? target.quote : undefined,
1290
+ },
1291
+ context: await askContext(
1292
+ input.agent,
1293
+ snapshot,
1294
+ checkout,
1295
+ input.selection,
1296
+ version,
1297
+ ),
1298
+ question: input.question,
1299
+ });
1300
+
1301
+ return context.json({ threadId: thread.id });
1302
+ });
1303
+
1304
+ app.get("/:id/ask/threads", (context) => {
1305
+ const reviewId = context.req.param("id");
1306
+
1307
+ readReview(reviewId);
1308
+
1309
+ return context.json({ threads: store.askHistory.list(reviewId) });
1310
+ });
1311
+
1312
+ // A saved conversation: attach to it if it is still running, else start
1313
+ // the agent and load it, at the commit it was asked about.
1314
+ app.post("/:id/ask/:threadId/open", async (context) => {
1315
+ const reviewId = context.req.param("id");
1316
+ const threadId = context.req.param("threadId");
1317
+ const live = ask.threads.get(threadId);
1318
+
1319
+ if (live?.reviewId === reviewId) return context.json({ threadId });
1320
+ const record = store.askHistory.get(threadId);
1321
+
1322
+ const { picks } = askOpenSchema.parse(
1323
+ await readBoundedRequestJson(context.req.raw, undefined, {}),
1324
+ );
1325
+
1326
+ if (record?.reviewId !== reviewId)
1327
+ throw new ReviewInputError("This conversation was not found.", 404);
1328
+
1329
+ const snapshot = readReview(reviewId, record.version);
1330
+ const checkout = await data.agentCheckout(snapshot);
1331
+ const target = record.selection.target;
1332
+
1333
+ ask.threads.open({
1334
+ id: record.id,
1335
+ reviewId,
1336
+ agent: record.agent,
1337
+ cwd: checkout.rootPath,
1338
+ head: checkout.head,
1339
+ selection: {
1340
+ title: record.selection.title,
1341
+ quote: target.kind === "text" ? target.quote : undefined,
1342
+ },
1343
+ resume: { sessionId: record.sessionId, entries: record.entries },
1344
+ // For a new session, should the agent no longer have this one.
1345
+ context: await askContext(
1346
+ record.agent,
1347
+ snapshot,
1348
+ checkout,
1349
+ record.selection,
1350
+ record.version,
1351
+ ),
1352
+ onSession: (sessionId) =>
1353
+ store.askHistory.updateSession(record.id, sessionId),
1354
+ onTurn: () => store.askHistory.touch(record.id),
1355
+ onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
1356
+ picks,
1357
+ bypass: record.bypass,
1358
+ onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
1359
+ onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
1360
+ onTitle: (title) => store.askHistory.rename(record.id, title),
1361
+ });
1362
+
1363
+ return context.json({ threadId: record.id });
1364
+ });
1365
+
1366
+ app.get("/:id/ask/:threadId/watch", (context) => {
1367
+ const thread = readThread(
1368
+ context.req.param("id"),
1369
+ context.req.param("threadId"),
1370
+ );
1371
+
1372
+ return watchAskThread(thread);
1373
+ });
1374
+
1375
+ app.post("/:id/ask/:threadId/prompt", async (context) => {
1376
+ const thread = readThread(
1377
+ context.req.param("id"),
1378
+ context.req.param("threadId"),
1379
+ );
1380
+
1381
+ const { question } = askFollowUpSchema.parse(
1382
+ await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES),
1383
+ );
1384
+
1385
+ const refusal = thread.askRefusal();
1386
+
1387
+ if (refusal) throw new ReviewInputError(refusal, 409);
1388
+
1389
+ void thread.ask(question);
1390
+
1391
+ return context.json({ ok: true });
1392
+ });
1393
+
1394
+ app.post("/:id/ask/:threadId/permission", async (context) => {
1395
+ const thread = readThread(
1396
+ context.req.param("id"),
1397
+ context.req.param("threadId"),
1398
+ );
1399
+
1400
+ const decision = askDecisionSchema.parse(
1401
+ await readBoundedRequestJson(context.req.raw),
1402
+ );
1403
+
1404
+ if (!thread.decide(decision.permissionId, decision.optionId))
1405
+ throw new ReviewInputError("This request was already answered.", 409);
1406
+
1407
+ return context.json({ ok: true });
1408
+ });
1409
+
1410
+ // The files an answer names, as the checkout's own paths, so the panel
1411
+ // can open them.
1412
+ app.post("/:id/ask/:threadId/files", async (context) => {
1413
+ const thread = readThread(
1414
+ context.req.param("id"),
1415
+ context.req.param("threadId"),
1416
+ );
1417
+
1418
+ const { paths } = askFilesSchema.parse(
1419
+ await readBoundedRequestJson(context.req.raw),
1420
+ );
1421
+
1422
+ const { cwd, entries } = thread.read();
1423
+
1424
+ const touched = entries.flatMap((entry) =>
1425
+ entry.kind === "tool"
1426
+ ? `${entry.title} ${entry.input ?? ""}`
1427
+ .split(/\s+/)
1428
+ .flatMap((token) => parseFileRef(token)?.path ?? [])
1429
+ : [],
1430
+ );
1431
+
1432
+ const files = resolveFileRefs(
1433
+ cwd,
1434
+ await checkoutFiles(cwd),
1435
+ paths,
1436
+ touched,
1437
+ );
1438
+
1439
+ return context.json({
1440
+ files: [...files].map(([path, file]) => ({ path, file })),
1441
+ });
1442
+ });
1443
+
1444
+ // Another model or effort for the next answer.
1445
+ app.post("/:id/ask/:threadId/choice", async (context) => {
1446
+ const thread = readThread(
1447
+ context.req.param("id"),
1448
+ context.req.param("threadId"),
1449
+ );
1450
+
1451
+ const { kind, value } = askChoiceSchema.parse(
1452
+ await readBoundedRequestJson(context.req.raw),
1453
+ );
1454
+
1455
+ if (thread.read().status !== "idle")
1456
+ throw new ReviewInputError(
1457
+ "Settings can change once the agent finishes answering.",
1458
+ 409,
1459
+ );
1460
+
1461
+ try {
1462
+ await thread.choose(kind, value);
1463
+ } catch (error) {
1464
+ throw new ReviewInputError(errorMessage(error), 409);
1465
+ }
1466
+
1467
+ return context.json({ ok: true });
1468
+ });
1469
+
1470
+ // Bypasses permissions from the next answer, or stops.
1471
+ app.post("/:id/ask/:threadId/permissions", async (context) => {
1472
+ const thread = readThread(
1473
+ context.req.param("id"),
1474
+ context.req.param("threadId"),
1475
+ );
1476
+
1477
+ const { bypass } = askPermitSchema.parse(
1478
+ await readBoundedRequestJson(context.req.raw),
1479
+ );
1480
+
1481
+ if (thread.read().status !== "idle")
1482
+ throw new ReviewInputError(
1483
+ "Settings can change once the agent finishes answering.",
1484
+ 409,
1485
+ );
1486
+
1487
+ try {
1488
+ await thread.permit(bypass);
1489
+ } catch (error) {
1490
+ throw new ReviewInputError(errorMessage(error), 409);
1491
+ }
1492
+
1493
+ return context.json({ ok: true });
1494
+ });
1495
+
1496
+ // Starts a failed agent again, as after signing it back in, and asks
1497
+ // again what it did not answer. The state reports how that goes.
1498
+ app.post("/:id/ask/:threadId/retry", (context) => {
1499
+ const thread = readThread(
1500
+ context.req.param("id"),
1501
+ context.req.param("threadId"),
1502
+ );
1503
+
1504
+ if (thread.read().status !== "failed")
1505
+ throw new ReviewInputError(
1506
+ "Only a conversation that failed can try again.",
1507
+ 409,
1508
+ );
1509
+ void thread.retry();
1510
+
1511
+ return context.json({ ok: true });
1512
+ });
1513
+
1514
+ app.post("/:id/ask/:threadId/cancel", async (context) => {
1515
+ await readThread(
1516
+ context.req.param("id"),
1517
+ context.req.param("threadId"),
1518
+ ).cancel();
1519
+
1520
+ return context.json({ ok: true });
1521
+ });
1522
+
1523
+ // Closing the panel ends the agent; the conversation stays saved.
1524
+ app.post("/:id/ask/:threadId/close", (context) => {
1525
+ readThread(context.req.param("id"), context.req.param("threadId"));
1526
+ ask.threads.close(context.req.param("threadId"));
1527
+
1528
+ return context.json({ ok: true });
1529
+ });
1530
+
1531
+ // Forgets a saved conversation. The agent keeps its own transcript.
1532
+ app.delete("/:id/ask/:threadId", (context) => {
1533
+ const reviewId = context.req.param("id");
1534
+ const threadId = context.req.param("threadId");
1535
+
1536
+ if (store.askHistory.get(threadId)?.reviewId !== reviewId)
1537
+ throw new ReviewInputError("This conversation was not found.", 404);
1538
+
1539
+ if (ask.threads.get(threadId)?.reviewId === reviewId)
1540
+ ask.threads.close(threadId);
1541
+ store.askHistory.delete(threadId);
1542
+
1543
+ return context.json({ ok: true });
1544
+ });
1545
+ }
1546
+
1547
+ app.get("/:id/stack", async (context) => {
1548
+ const query = readQuerySchemas.get.parse(context.req.query());
1549
+ const id = context.req.param("id");
1550
+ const snapshot = readReview(id, query.version);
1551
+
1552
+ if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
1553
+ const layers = await resolveReviewStackLayers(snapshot, store.list());
1554
+
1555
+ return context.json({ layers });
1556
+ });
1557
+
1558
+ app.get("/:id/history", (context) => {
1559
+ const id = context.req.param("id");
1560
+
1561
+ if (!isShared(id)) return context.json(store.history(id));
1562
+ const snapshot = readReview(id);
1563
+
1564
+ return context.json([
1565
+ {
1566
+ version: snapshot.version,
1567
+ title: snapshot.title,
1568
+ createdAt: snapshot.createdAt,
1569
+ },
1570
+ ]);
1571
+ });
1572
+ app.get("/:id/inspect", (context) => {
1573
+ const query = inspectQuerySchema.parse(context.req.query());
1574
+ const id = context.req.param("id");
1575
+ const snapshot = readReview(id, query.version);
1576
+
1577
+ return context.json(
1578
+ query.format === "text"
1579
+ ? documentText(snapshot, query.targetId, Boolean(query.full))
1580
+ : query.targetId !== undefined
1581
+ ? inspectSnapshot(snapshot, query.targetId)
1582
+ : query.full
1583
+ ? snapshot
1584
+ : inspectSnapshot(snapshot),
1585
+ );
1586
+ });
1587
+ app.get("/:id", async (context) => {
1588
+ const query = readQuerySchemas.get.parse(context.req.query());
1589
+
1590
+ const snapshot = { ...readReview(context.req.param("id"), query.version) };
1591
+
1592
+ if (data && query.full) {
1593
+ try {
1594
+ const pins = await data.sourcePins(snapshot);
1595
+
1596
+ if (pins) snapshot.pins = pins;
1597
+ } catch (error) {
1598
+ if (!(error instanceof ReviewInputError) || error.status !== 404)
1599
+ throw error;
1600
+ snapshot.sourceUnavailable = true;
1601
+ }
1602
+ }
1603
+
1604
+ return context.json(
1605
+ query.full ? snapshot : inspectSnapshot(snapshot, query.targetId),
1606
+ );
1607
+ });
1608
+
1609
+ /** After a lens write: the changed lines still uncategorized at that
1610
+ * version, so the author can fill the gaps. A comparison that cannot be
1611
+ * read leaves a warning instead of failing the saved write. */
1612
+ const lensGaps = async (
1613
+ reviewId: string,
1614
+ version: number,
1615
+ request: Request,
1616
+ ): Promise<{ uncategorized?: UncategorizedReport; warnings?: string[] }> => {
1617
+ if (!data) return {};
1618
+
1619
+ try {
1620
+ return {
1621
+ uncategorized: uncategorizedReport(
1622
+ await reviewProgress(
1623
+ store,
1624
+ data,
1625
+ store.read(reviewId, version),
1626
+ request.signal,
1627
+ ),
1628
+ ),
1629
+ };
1630
+ } catch (error) {
1631
+ return {
1632
+ warnings: [
1633
+ `Uncategorized changes are unavailable: ${errorMessage(error)}`,
1634
+ ],
1635
+ };
1636
+ }
1637
+ };
1638
+
1639
+ app.post("/commands", async (context) => {
1640
+ const { command: body, open: requestedOpen } = takeCreateOpen(
1641
+ await readBoundedRequestJson(context.req.raw),
1642
+ );
1643
+
1644
+ const request = await locateRepositories(body, (path) => {
1645
+ if (!data) throw new ReviewInputError("Repositories are unavailable.");
1646
+
1647
+ return data.register(path);
1648
+ });
1649
+
1650
+ const input = commandSchema.parse(request);
1651
+
1652
+ const command = sharedCommandSchema.safeParse(input);
1653
+
1654
+ if (command.success) {
1655
+ refuseDisabledScratchpad(command.data.operation.reviewId);
1656
+ await ensureScratchpad(command.data.operation.reviewId);
1657
+ }
1658
+
1659
+ if (
1660
+ input.operation.type === "create" &&
1661
+ input.operation.kind === "scratchpad"
1662
+ )
1663
+ refuseDisabledScratchpad(SCRATCHPAD_ID);
1664
+
1665
+ if (
1666
+ command.success &&
1667
+ command.data.operation.reviewId?.startsWith("shared-")
1668
+ ) {
1669
+ const parsed = commandSchema.parse(input);
1670
+
1671
+ if (shared && parsed.operation.type === "delete") {
1672
+ await shared.removeLocal(parsed.operation.reviewId);
1673
+
1674
+ return context.json({
1675
+ reviewId: parsed.operation.reviewId,
1676
+ version: 0,
1677
+ deleted: true,
1678
+ });
1679
+ }
1680
+
1681
+ if (shared && parsed.operation.type === "attention") {
1682
+ await shared.setAttention(
1683
+ parsed.operation.reviewId,
1684
+ parsed.operation.action,
1685
+ );
1686
+
1687
+ return context.json({
1688
+ reviewId: parsed.operation.reviewId,
1689
+ version: shared.get(parsed.operation.reviewId).snapshot.version,
1690
+ attention: true,
1691
+ });
1692
+ }
1693
+
1694
+ throw new ReviewInputError("Shared reviews are read-only.", 409);
1695
+ }
1696
+
1697
+ const result = await store.execute(input);
1698
+
1699
+ if (input.operation.type === "edit" && data && !result.deleted) {
1700
+ const { quotes, unquoted } = await anchorQuotes(
1701
+ result.version > 0
1702
+ ? store.read(result.reviewId, result.version - 1)
1703
+ : undefined,
1704
+ store.read(result.reviewId, result.version),
1705
+ async (pins, side, file) => (await data.file(pins, side, file)).text,
1706
+ );
1707
+
1708
+ return context.json({
1709
+ ...result,
1710
+ ...(quotes.length && { quotes }),
1711
+ ...(unquoted && { unquotedAnchors: unquoted }),
1712
+ });
1713
+ }
1714
+
1715
+ if (input.operation.type === "lens_edit") {
1716
+ const gaps = await lensGaps(
1717
+ result.reviewId,
1718
+ result.version,
1719
+ context.req.raw,
1720
+ );
1721
+
1722
+ return context.json({
1723
+ ...result,
1724
+ ...gaps,
1725
+ ...(gaps.warnings && {
1726
+ warnings: [...(result.warnings ?? []), ...gaps.warnings],
1727
+ }),
1728
+ });
1729
+ }
1730
+
1731
+ if (input.operation.type !== "create") return context.json(result);
1732
+
1733
+ // False when an existing review for the same PR came back.
1734
+ if (result.created !== false)
1735
+ hooks.onReviewCreated?.({
1736
+ reviewId: result.reviewId,
1737
+ kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
1738
+ blocks: store.read(result.reviewId).document.length,
1739
+ ...reviewRequestOrigin(context.req.raw.headers),
1740
+ });
1741
+
1742
+ return context.json({
1743
+ ...result,
1744
+ review: store.summary(result.reviewId),
1745
+ ...(requestedOpen === false
1746
+ ? { opened: false }
1747
+ : await openCreated(result.reviewId)),
1748
+ });
1749
+ });
1750
+
1751
+ return app;
1752
+ }
1753
+
1754
+ const systemErrorSchema = z.object({ code: z.string().regex(/^[A-Z0-9_]+$/) });
1755
+
1756
+ /** A system error code such as EACCES, else the error's class; never its message. */
1757
+ function failureKind(error: Error): string {
1758
+ const system = systemErrorSchema.safeParse(error);
1759
+
1760
+ return system.success ? system.data.code : error.name;
1761
+ }
1762
+
1763
+ /**
1764
+ * `open` steers presentation, not the saved review, so it stays out of the
1765
+ * command. Anything else, including `open` off create, is left for
1766
+ * commandSchema to reject.
1767
+ */
1768
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
1769
+ function takeCreateOpen(body: unknown) {
1770
+ const create = z
1771
+ .looseObject({
1772
+ operation: z.looseObject({
1773
+ type: z.literal("create"),
1774
+ open: z.boolean().optional(),
1775
+ }),
1776
+ })
1777
+ .safeParse(body);
1778
+
1779
+ if (!create.success) return { command: body };
1780
+ const { open, ...operation } = create.data.operation;
1781
+
1782
+ return { command: { ...create.data, operation }, open };
1783
+ }
1784
+
1785
+ /**
1786
+ * Agents name a checkout by its path (pathTargetSchema, and repositoryPath on
1787
+ * a create from a PR); the store keeps the id it registers as. A command that
1788
+ * already names ids is left as it is.
1789
+ */
1790
+ async function locateRepositories(
1791
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
1792
+ body: unknown,
1793
+ register: (path: string) => Promise<{ id: string }>,
1794
+ ) {
1795
+ const located = z
1796
+ .looseObject({
1797
+ operation: z.looseObject({
1798
+ type: z.enum(["create", "set_target"]),
1799
+ target: z.looseObject({ repositoryPath: z.string() }).optional(),
1800
+ repositoryPath: z.string().optional(),
1801
+ }),
1802
+ })
1803
+ .safeParse(body);
1804
+
1805
+ if (!located.success) return body;
1806
+ const { target, repositoryPath, ...operation } = located.data.operation;
1807
+
1808
+ const byId = async <Named extends { repositoryPath: string }>({
1809
+ repositoryPath: path,
1810
+ ...rest
1811
+ }: Named) => ({ ...rest, repositoryId: (await register(path)).id });
1812
+
1813
+ return {
1814
+ ...located.data,
1815
+ operation: {
1816
+ ...operation,
1817
+ ...(target && { target: await byId(target) }),
1818
+ ...(repositoryPath && (await byId({ repositoryPath }))),
1819
+ },
1820
+ };
1821
+ }
1822
+
1823
+ /** Send committed state, coalescing updates when the reader falls behind. */
1824
+ function watch<T>(
1825
+ read: () => T,
1826
+ subscribe: (notify: () => void) => () => void,
1827
+ probe: () => void = read,
1828
+ ) {
1829
+ probe(); // Return a normal 404 before opening the response.
1830
+ let stop = () => {};
1831
+
1832
+ let dirty = true;
1833
+ const encoder = new TextEncoder();
1834
+
1835
+ const send = (controller: ReadableStreamDefaultController<Uint8Array>) => {
1836
+ if (
1837
+ !dirty ||
1838
+ controller.desiredSize === null ||
1839
+ controller.desiredSize <= 0
1840
+ )
1841
+ return;
1842
+
1843
+ try {
1844
+ const line = JSON.stringify(read()) + "\n";
1845
+
1846
+ // enqueue can pull synchronously; clear first so it doesn't resend.
1847
+ dirty = false;
1848
+ controller.enqueue(encoder.encode(line));
1849
+ } catch (error) {
1850
+ // A review can be deleted while this stream is open. Do not throw into
1851
+ // the already-committed writer; close this reader and unsubscribe it.
1852
+ stop();
1853
+ controller.error(error);
1854
+ }
1855
+ };
1856
+
1857
+ const body = new ReadableStream<Uint8Array>({
1858
+ start(controller) {
1859
+ stop = subscribe(() => {
1860
+ dirty = true;
1861
+ send(controller);
1862
+ });
1863
+ send(controller);
1864
+ },
1865
+ pull: send,
1866
+ cancel() {
1867
+ stop();
1868
+ },
1869
+ });
1870
+
1871
+ return new Response(body, {
1872
+ headers: {
1873
+ "content-type": "application/x-ndjson",
1874
+ "cache-control": "no-store",
1875
+ },
1876
+ });
1877
+ }