@dev.fast/whiteboard 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,1647 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+
5
+ import { resolveRepoContextSync } from "@dev.fast/local-vcs";
6
+ import {
7
+ type ReviewApiSummary,
8
+ SCRATCHPAD_REVIEW_ID,
9
+ } from "@dev.fast/review-protocol";
10
+ import { sourceAnchors } from "@review/lens-selection.js";
11
+ import {
12
+ liftFileLenses,
13
+ migrateStoredDocument,
14
+ migrateStoredLenses,
15
+ } from "@review/stored-document-migration.js";
16
+ import {
17
+ type Coverage,
18
+ coverageSchema,
19
+ emptyCoverage,
20
+ updateCoverage,
21
+ } from "@review/viewed-coverage.js";
22
+ import { z } from "zod";
23
+
24
+ import { type ActivitySurface, ReviewActivity } from "./activity.js";
25
+ import { AskHistory } from "./ask-history.js";
26
+ import {
27
+ type Lens,
28
+ applyLensEdit,
29
+ lensEditSchema,
30
+ lensSelections,
31
+ } from "./diff-lenses.js";
32
+ import {
33
+ type Applied,
34
+ type Block,
35
+ type EditSummary,
36
+ type Element,
37
+ type FileLineRange,
38
+ type Pins,
39
+ ReviewInputError,
40
+ type ReviewTarget,
41
+ type WrittenComponent,
42
+ anchorPins,
43
+ applyEdit,
44
+ assignFreshIds,
45
+ checkReferences,
46
+ documentSchema,
47
+ editSchema,
48
+ elements,
49
+ explicitPins,
50
+ isUnit,
51
+ resourceReferences,
52
+ reviewTargetSchema,
53
+ sourceReferences,
54
+ summarizeEdit,
55
+ } from "./document.js";
56
+ import { pullRequestKey, pullRequestUrl, setPullRequest } from "./origin.js";
57
+ import { initializeReviewStoreSchema } from "./store-schema.js";
58
+
59
+ const reviewId = z.string().min(1);
60
+
61
+ /** There is one scratchpad. Its id is fixed so a skill can name it. */
62
+ export const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
63
+
64
+ const DIAGRAM_TYPES = new Set([
65
+ "sequence",
66
+ "flow_diagram",
67
+ "call_stack_diff",
68
+ "database_lens",
69
+ "software_map",
70
+ ]);
71
+
72
+ export const SCRATCHPAD_TITLE = "Scratchpad";
73
+
74
+ const activityId = z
75
+ .string()
76
+ .min(1)
77
+ .optional()
78
+ .describe(
79
+ "The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.",
80
+ );
81
+
82
+ export const commandSchema = z.strictObject({
83
+ operation: z.discriminatedUnion("type", [
84
+ z.strictObject({ type: z.literal("delete"), reviewId }),
85
+ z.strictObject({
86
+ type: z.literal("attention"),
87
+ reviewId,
88
+ action: z.enum(["view", "dismiss", "restore"]),
89
+ }),
90
+ z.strictObject({
91
+ type: z.literal("create"),
92
+ /** Required unless pullRequestUrl alone names the source; then the PR title. */
93
+ title: z.string().trim().min(1).optional(),
94
+ target: reviewTargetSchema.optional(),
95
+ pullRequestUrl: pullRequestUrl.optional(),
96
+ /** With pullRequestUrl and no target: the checkout to fetch the PR into. */
97
+ repositoryId: z
98
+ .string()
99
+ .min(1)
100
+ .optional()
101
+ .describe(
102
+ "Only with pullRequestUrl and no target: the registered checkout to fetch the PR into. Default: the existing review's, else the first registered checkout with a remote for the PR's repository.",
103
+ ),
104
+ /** Return the existing review for pullRequestUrl instead of creating one. */
105
+ reuseExisting: z
106
+ .boolean()
107
+ .optional()
108
+ .describe(
109
+ "Default true: return the existing review for pullRequestUrl. false creates a separate review.",
110
+ ),
111
+ /** The one scratchpad: no target, no pins; every reference names its own. */
112
+ kind: z.literal("scratchpad").optional(),
113
+ }),
114
+ z.strictObject({
115
+ type: z.literal("set_target"),
116
+ reviewId,
117
+ target: reviewTargetSchema,
118
+ pullRequestUrl: pullRequestUrl.nullable().optional(),
119
+ }),
120
+ z.strictObject({
121
+ type: z.literal("edit"),
122
+ reviewId,
123
+ edit: editSchema,
124
+ activityId,
125
+ }),
126
+ z.strictObject({
127
+ type: z.literal("lens_edit"),
128
+ reviewId,
129
+ edit: lensEditSchema,
130
+ activityId,
131
+ }),
132
+ z.strictObject({
133
+ type: z.literal("rename"),
134
+ reviewId,
135
+ title: z.string().trim().min(1),
136
+ }),
137
+ z.strictObject({
138
+ type: z.literal("restore"),
139
+ reviewId,
140
+ version: z.number().int().nonnegative(),
141
+ }),
142
+ ]),
143
+ });
144
+
145
+ /** Source identity displayed in the review header and Home, alongside immutable pins. */
146
+ export interface SnapshotOrigin {
147
+ /** Managed tutorial; readable by ID but excluded from the user catalog. */
148
+ tutorial?: boolean;
149
+ branch?: string;
150
+ baseRef?: string;
151
+ pullRequestNumber?: number;
152
+ pullRequestUrl?: string;
153
+ /** The legacy review revision this version was imported from. */
154
+ revision?: string;
155
+ }
156
+
157
+ export interface Snapshot {
158
+ shared?: { login?: string; sharedAt?: number; cloneUrl?: string };
159
+ reviewId: string;
160
+ version: number;
161
+ title: string;
162
+ /** Absent for a review. The scratchpad has no pins, target or lifecycle. */
163
+ kind?: "scratchpad";
164
+ /** The default pins for references that name none. A document whose
165
+ * references all carry their own pins has neither pins nor target. */
166
+ pins?: Pins;
167
+ target?: ReviewTarget;
168
+ staleSources?: string[];
169
+ sourceUnavailable?: boolean;
170
+ document: Block[];
171
+ /** The Diff view's lenses, beside the document and versioned with it.
172
+ * Absent on a version with none. */
173
+ lenses?: Lens[];
174
+ createdAt: string;
175
+ origin?: SnapshotOrigin;
176
+ /** The edit that produced this version, when one did; absent for a
177
+ * rename, set_target, restore or import, which the canvas does not draw. */
178
+ lastEdit?: EditSummary;
179
+ }
180
+
181
+ export interface Result {
182
+ /** Create only: false when an existing review for the same PR came back. */
183
+ created?: boolean;
184
+ /** Why an existing review came back, and what to do next, in words. */
185
+ note?: string;
186
+ reviewId: string;
187
+ version: number;
188
+ /** The existing review's stored target; the requested one is not applied. */
189
+ target?: ReviewTarget;
190
+ /** The requested head differs from the existing review's. */
191
+ headMoved?: boolean;
192
+ /** An agent is working on the existing review. */
193
+ working?: boolean;
194
+ /** Older reviews that also name the PR, newest first. */
195
+ otherReviewIds?: string[];
196
+ /** The component an edit landed on, its type, and — for an insert or
197
+ * replace — its first-level children with their fresh IDs. */
198
+ targetId?: string;
199
+ type?: Element["type"] | "lens";
200
+ children?: WrittenComponent[];
201
+ attention?: true;
202
+ deleted?: true;
203
+ warnings?: string[];
204
+ }
205
+
206
+ /** A PR's current comparison: GitHub's head and diff base, fetched locally. */
207
+ export interface ResolvedPullRequest {
208
+ target: ReviewTarget;
209
+ pins: Pins;
210
+ title: string;
211
+ }
212
+
213
+ export interface ReviewProviders {
214
+ headBranch?(pins: Pins, headRef?: string): Promise<string | undefined>;
215
+ projectSource?(snapshot: Snapshot, pins: Pins): Promise<Snapshot>;
216
+ /** Fetch a PR into a registered checkout: the named one, else the first
217
+ * preferred one that still matches, else any whose remote is the PR's. */
218
+ resolvePullRequest?(
219
+ url: string,
220
+ repository: { id?: string; preferred?: string },
221
+ ): Promise<ResolvedPullRequest>;
222
+ resolveTarget?(
223
+ target: ReviewTarget,
224
+ pinned?: Pins,
225
+ ): Promise<{ target: ReviewTarget; pins: Pins }>;
226
+ /** Rejects with a 404 ReviewInputError when the snapshot's checkout is gone.
227
+ * Resolves undefined for a document without default pins. */
228
+ sourcePins?(snapshot: Snapshot): Promise<Pins | undefined>;
229
+ /** Ids of references whose own pins no longer name a usable checkout. */
230
+ unavailableAnchors?(snapshot: Snapshot): Promise<string[]>;
231
+ validatePins(pins: Pins): Promise<void>;
232
+ /** The files each side's commit changed from one set of pins to another,
233
+ * or undefined when they can't be diffed commit to commit. */
234
+ filesChangedBetween?(
235
+ from: Pins,
236
+ to: Pins,
237
+ ): Promise<{ base: Set<string>; head: Set<string> } | undefined>;
238
+ validateSource(
239
+ pins: Pins,
240
+ source: FileLineRange,
241
+ options: { peek: boolean },
242
+ ): Promise<void>;
243
+ validateResource(pins: Pins | undefined, block: Block): Promise<void>;
244
+ }
245
+
246
+ /** One instance owned by the desktop server. All writers go through execute().
247
+ * The queue includes async validation; SQLite transactions contain only writes.
248
+ * This prototype uses a new, explicitly supplied database, never an existing profile.
249
+ */
250
+ export class ReviewStore {
251
+ readonly activity: ReviewActivity;
252
+ readonly askHistory: AskHistory;
253
+ private readonly db: DatabaseSync;
254
+ private pending: Promise<unknown> = Promise.resolve();
255
+ private closing = false;
256
+ private readonly liveSources = new Map<string, Snapshot>();
257
+ private refreshTimer?: ReturnType<typeof setInterval>;
258
+ private refreshSubscribers = 0;
259
+
260
+ watchWorktrees(): () => void {
261
+ this.refreshSubscribers++;
262
+ this.refreshTimer ??= setInterval(() => {
263
+ void this.refreshWorktrees();
264
+ }, 1000);
265
+ this.refreshTimer.unref();
266
+
267
+ return () => {
268
+ if (--this.refreshSubscribers === 0) {
269
+ clearInterval(this.refreshTimer);
270
+ this.refreshTimer = undefined;
271
+ }
272
+ };
273
+ }
274
+
275
+ private async projectLiveSource(
276
+ snapshot: Snapshot,
277
+ current = snapshot,
278
+ ): Promise<Snapshot> {
279
+ if (snapshot.target?.kind !== "worktree") {
280
+ await this.providers.sourcePins?.(snapshot);
281
+
282
+ // References with their own pins go unavailable one at a time.
283
+ const unavailable =
284
+ (await this.providers.unavailableAnchors?.(snapshot)) ?? [];
285
+
286
+ const stale = [
287
+ ...new Set([...(snapshot.staleSources ?? []), ...unavailable]),
288
+ ];
289
+
290
+ const staleChanged =
291
+ JSON.stringify(stale) !== JSON.stringify(current.staleSources ?? []);
292
+
293
+ if (!current.sourceUnavailable && !staleChanged) return current;
294
+ const projected = { ...current, sourceUnavailable: undefined };
295
+
296
+ if (stale.length) projected.staleSources = stale;
297
+ else delete projected.staleSources;
298
+
299
+ return projected;
300
+ }
301
+
302
+ const { pins } = await this.providers.resolveTarget!(
303
+ snapshot.target,
304
+ current.pins,
305
+ );
306
+
307
+ if (
308
+ JSON.stringify(current.pins) === JSON.stringify(pins) &&
309
+ !current.sourceUnavailable
310
+ )
311
+ return current;
312
+
313
+ return this.providers.projectSource
314
+ ? this.providers.projectSource(snapshot, pins)
315
+ : { ...snapshot, pins, sourceUnavailable: undefined };
316
+ }
317
+
318
+ private refreshPending: Promise<void> | undefined;
319
+
320
+ /** Refresh source state without writing authored document versions. Serialized with edits. */
321
+ refreshWorktrees(): Promise<void> {
322
+ if (this.closing || !this.providers.resolveTarget) return Promise.resolve();
323
+
324
+ if (this.refreshPending) return this.refreshPending;
325
+
326
+ const run = this.pending.then(async () => {
327
+ for (const summary of this.list()) {
328
+ const snapshot = this.read(summary.reviewId, summary.version);
329
+
330
+ try {
331
+ const live = this.liveSources.get(summary.reviewId);
332
+ const last = live?.version === snapshot.version ? live : snapshot;
333
+ const previous = last.pins;
334
+ const projected = await this.projectLiveSource(snapshot, last);
335
+
336
+ if (projected === last) continue;
337
+ this.liveSources.set(snapshot.reviewId, projected);
338
+
339
+ if (
340
+ JSON.stringify(previous) !== JSON.stringify(projected.pins) ||
341
+ JSON.stringify(last.staleSources ?? []) !==
342
+ JSON.stringify(projected.staleSources ?? []) ||
343
+ last.sourceUnavailable
344
+ )
345
+ this.notify({
346
+ reviewId: snapshot.reviewId,
347
+ version: snapshot.version,
348
+ });
349
+ } catch (error) {
350
+ if (error instanceof ReviewInputError && error.status === 404) {
351
+ const last = this.read(snapshot.reviewId);
352
+
353
+ if (!last.sourceUnavailable) {
354
+ this.liveSources.set(snapshot.reviewId, {
355
+ ...last,
356
+ sourceUnavailable: true,
357
+ });
358
+ this.notify({
359
+ reviewId: snapshot.reviewId,
360
+ version: snapshot.version,
361
+ });
362
+ }
363
+ }
364
+ }
365
+ }
366
+ });
367
+
368
+ this.pending = run.catch(() => {});
369
+
370
+ this.refreshPending = run.finally(() => {
371
+ this.refreshPending = undefined;
372
+ });
373
+
374
+ return this.refreshPending;
375
+ }
376
+ private readonly listeners = new Set<(result: Result) => void>();
377
+ private readonly catalogListeners = new Set<() => void>();
378
+ private readonly externalChanges: ReturnType<typeof setInterval>;
379
+ private observedDataVersion: number;
380
+ private observedVersions = new Map<string, number>();
381
+ subscribeCatalog(listener: () => void) {
382
+ this.catalogListeners.add(listener);
383
+
384
+ return () => {
385
+ this.catalogListeners.delete(listener);
386
+ };
387
+ }
388
+ /** For a host whose listing changed without the store: a preference flip. */
389
+ invalidateCatalog(): void {
390
+ for (const listener of this.catalogListeners)
391
+ try {
392
+ listener();
393
+ } catch {
394
+ // A disconnected viewer must not block other catalog subscribers.
395
+ }
396
+ }
397
+ subscribe(listener: (result: Result) => void) {
398
+ this.listeners.add(listener);
399
+
400
+ return () => {
401
+ this.listeners.delete(listener);
402
+ };
403
+ }
404
+ constructor(
405
+ databasePath: string,
406
+ private readonly providers: ReviewProviders,
407
+ ) {
408
+ // WAL plus a busy timeout: another host on the same home waits instead of failing.
409
+ this.db = new DatabaseSync(databasePath, { timeout: 5000 });
410
+ initializeReviewStoreSchema(this.db);
411
+ this.activity = new ReviewActivity(this.db, (id) => this.assertExists(id));
412
+ this.askHistory = new AskHistory(this.db);
413
+
414
+ this.observedDataVersion = this.dataVersion();
415
+ this.observedVersions = this.currentVersions();
416
+ this.externalChanges = setInterval(
417
+ () => this.refreshExternalChanges(),
418
+ 250,
419
+ );
420
+ this.externalChanges.unref();
421
+ }
422
+
423
+ private dataVersion() {
424
+ return Number(this.db.prepare("PRAGMA data_version").get()!.data_version);
425
+ }
426
+
427
+ private currentVersions() {
428
+ return new Map(
429
+ this.db
430
+ .prepare("SELECT id,version FROM reviews")
431
+ .all()
432
+ .map((row) => [String(row.id), Number(row.version)]),
433
+ );
434
+ }
435
+
436
+ private refreshExternalChanges() {
437
+ const version = this.dataVersion();
438
+
439
+ if (version === this.observedDataVersion) return;
440
+ this.observedDataVersion = version;
441
+ const current = this.currentVersions();
442
+ const previous = this.observedVersions;
443
+ this.observedVersions = current;
444
+
445
+ for (const [reviewId, savedVersion] of current)
446
+ if (previous.get(reviewId) !== savedVersion)
447
+ this.notify({ reviewId, version: savedVersion });
448
+
449
+ for (const [reviewId, savedVersion] of previous)
450
+ if (!current.has(reviewId)) {
451
+ this.activity.deleted(reviewId);
452
+ this.notify({ reviewId, version: savedVersion, deleted: true });
453
+ }
454
+
455
+ this.activity.refresh();
456
+
457
+ // Attention and repository/resource changes need catalog invalidation too.
458
+ for (const listener of this.catalogListeners) {
459
+ try {
460
+ listener();
461
+ } catch {
462
+ /* Disconnected readers do not stop polling. */
463
+ }
464
+ }
465
+ }
466
+ /** Reader progress never creates a document version or authoring event. */
467
+ viewedCoverage(
468
+ reviewId: string,
469
+ ): Map<string, { fingerprint: string; coverage: Coverage }> {
470
+ this.assertExists(reviewId);
471
+
472
+ return new Map(
473
+ this.db
474
+ .prepare(
475
+ "SELECT file,fingerprint,coverage FROM review_coverage WHERE review_id=?",
476
+ )
477
+ .all(reviewId)
478
+ .map((row) => [
479
+ String(row.file),
480
+ {
481
+ fingerprint: String(row.fingerprint),
482
+ coverage: coverageSchema.parse(JSON.parse(String(row.coverage))),
483
+ },
484
+ ]),
485
+ );
486
+ }
487
+ updateViewedCoverage(
488
+ reviewId: string,
489
+ files: { path: string; fingerprint: string; scope: Coverage }[],
490
+ viewed: boolean,
491
+ ): void {
492
+ this.assertExists(reviewId);
493
+ this.db.exec("BEGIN IMMEDIATE");
494
+
495
+ try {
496
+ const current = this.viewedCoverage(reviewId);
497
+
498
+ for (const file of files) {
499
+ const previous = current.get(file.path);
500
+
501
+ const coverage = updateCoverage(
502
+ previous?.fingerprint === file.fingerprint
503
+ ? previous.coverage
504
+ : emptyCoverage(),
505
+ file.scope,
506
+ viewed,
507
+ );
508
+
509
+ this.db
510
+ .prepare(
511
+ "INSERT OR REPLACE INTO review_coverage(review_id,file,fingerprint,coverage) VALUES(?,?,?,?)",
512
+ )
513
+ .run(reviewId, file.path, file.fingerprint, JSON.stringify(coverage));
514
+ }
515
+
516
+ this.db.exec("COMMIT");
517
+ } catch (error) {
518
+ this.db.exec("ROLLBACK");
519
+ throw error;
520
+ }
521
+ }
522
+ private readonly repositoryGroups = new Map<
523
+ string,
524
+ ReviewApiSummary["repositoryGroup"]
525
+ >();
526
+
527
+ private repositoryGroup(root: string): ReviewApiSummary["repositoryGroup"] {
528
+ if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
529
+
530
+ const context = resolveRepoContextSync(root);
531
+
532
+ if (!context) return undefined;
533
+
534
+ const group = context.githubSlug
535
+ ? {
536
+ key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
537
+ label: context.githubSlug,
538
+ }
539
+ : {
540
+ key: `git:${context.commonDir}`,
541
+ label: path.basename(path.dirname(context.commonDir)),
542
+ };
543
+
544
+ this.repositoryGroups.set(root, group);
545
+
546
+ return group;
547
+ }
548
+
549
+ registerRepository(root: string) {
550
+ this.db
551
+ .prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)")
552
+ .run(randomUUID(), root, root.split(/[\\/]/).at(-1)!);
553
+
554
+ const row = this.db
555
+ .prepare("SELECT id,name FROM repositories WHERE path=?")
556
+ .get(root)!;
557
+
558
+ return { id: String(row.id), name: String(row.name) };
559
+ }
560
+ unregisterRepository(id: string) {
561
+ this.db
562
+ // Document pins and per-reference pins both spell the id in the snapshot.
563
+ .prepare(`DELETE FROM repositories WHERE id=?
564
+ AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
565
+ AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`)
566
+ .run(id, `"repositoryId":${JSON.stringify(id)}`, id);
567
+ }
568
+ /** Registered checkouts, oldest registration first. */
569
+ repositories() {
570
+ return this.db
571
+ .prepare("SELECT id,path FROM repositories ORDER BY rowid")
572
+ .all()
573
+ .map((row) => ({ id: String(row.id), path: String(row.path) }));
574
+ }
575
+ repositoryPath(id: string) {
576
+ const row = this.db
577
+ .prepare("SELECT path FROM repositories WHERE id=?")
578
+ .get(id);
579
+
580
+ if (!row) throw new ReviewInputError("Repository is not registered.", 404);
581
+
582
+ return String(row.path);
583
+ }
584
+ putResource(
585
+ id: string,
586
+ repositoryId: string,
587
+ kind: string,
588
+ mimeType: string,
589
+ data: Uint8Array,
590
+ ) {
591
+ this.db
592
+ .prepare(
593
+ "INSERT OR IGNORE INTO resources(id,repository_id,kind,mime_type,data) VALUES(?,?,?,?,?)",
594
+ )
595
+ .run(id, repositoryId, kind, mimeType, data);
596
+ const saved = this.resource(id);
597
+
598
+ if (
599
+ saved.repositoryId !== repositoryId ||
600
+ saved.kind !== kind ||
601
+ saved.mimeType !== mimeType ||
602
+ !Buffer.from(saved.data).equals(data)
603
+ )
604
+ throw new ReviewInputError(
605
+ "Resource ID was already used for different content.",
606
+ 409,
607
+ );
608
+
609
+ return { id, kind, mimeType };
610
+ }
611
+ resource(id: string) {
612
+ const row = this.db.prepare("SELECT * FROM resources WHERE id=?").get(id);
613
+
614
+ if (!row) throw new ReviewInputError("Resource not found.", 404);
615
+
616
+ return {
617
+ id,
618
+ repositoryId: String(row.repository_id),
619
+ kind: String(row.kind),
620
+ mimeType: String(row.mime_type),
621
+ // SAFETY: resources.data is a BLOB written by putResource; node:sqlite returns Uint8Array.
622
+ data: row.data as Uint8Array,
623
+ };
624
+ }
625
+ async close() {
626
+ this.closing = true;
627
+ clearInterval(this.externalChanges);
628
+ clearInterval(this.refreshTimer);
629
+ await this.pending;
630
+ this.listeners.clear();
631
+ this.catalogListeners.clear();
632
+ this.activity.close();
633
+ this.db.close();
634
+ }
635
+ /** Stable across restarts; the first host on a new store chooses it. */
636
+ serverId(): string {
637
+ const read = () =>
638
+ this.db.prepare("SELECT id FROM server_identity").get()?.id;
639
+
640
+ let id = read();
641
+
642
+ if (id === undefined) {
643
+ // Another host may insert first; its id wins.
644
+ this.db
645
+ .prepare("INSERT OR IGNORE INTO server_identity(one,id) VALUES(1,?)")
646
+ .run(randomUUID());
647
+ id = read();
648
+ }
649
+
650
+ return String(id);
651
+ }
652
+ resetServerId(): string {
653
+ const id = randomUUID();
654
+ this.db
655
+ .prepare("INSERT OR REPLACE INTO server_identity(one,id) VALUES(1,?)")
656
+ .run(id);
657
+
658
+ return id;
659
+ }
660
+ /** The 404 check alone, without loading a snapshot. */
661
+ assertExists(id: string) {
662
+ if (!this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(id))
663
+ throw new ReviewInputError(
664
+ "Review not found. If this is an old Whiteboard review, ask your agent to migrate your old Whiteboard reviews.",
665
+ 404,
666
+ );
667
+ }
668
+ read(id: string, version?: number): Snapshot {
669
+ const row =
670
+ version === undefined
671
+ ? this.db
672
+ .prepare(
673
+ "SELECT snapshot FROM versions JOIN reviews ON reviews.id=review_id AND reviews.version=versions.version WHERE reviews.id=?",
674
+ )
675
+ .get(id)
676
+ : this.db
677
+ .prepare(
678
+ "SELECT snapshot FROM versions WHERE review_id=? AND version=?",
679
+ )
680
+ .get(id, version);
681
+
682
+ if (!row) {
683
+ this.assertExists(id);
684
+ throw new ReviewInputError("Review version not found.", 404);
685
+ }
686
+
687
+ // SAFETY: versions contains only snapshots validated by execute before committing.
688
+ const snapshot = JSON.parse(String(row.snapshot)) as Snapshot;
689
+
690
+ // SAFETY: stored blocks were validated on write; migration only replaces
691
+ // retired attachment representations with their canonical equivalent and
692
+ // drops retired fields.
693
+ // Lenses saved as document blocks read as the snapshot's own.
694
+ const { document, lenses } = liftFileLenses(
695
+ migrateStoredDocument(snapshot.document),
696
+ );
697
+
698
+ // SAFETY: stored blocks were validated on write; migration only replaces
699
+ // retired representations and lifts retired lens blocks out.
700
+ snapshot.document = document as Block[];
701
+
702
+ if (snapshot.lenses)
703
+ // SAFETY: as above, migration only rewrites retired range forms.
704
+ snapshot.lenses = migrateStoredLenses(snapshot.lenses) as Lens[];
705
+
706
+ if (lenses.length)
707
+ snapshot.lenses = [...(snapshot.lenses ?? []), ...lenses];
708
+
709
+ if (snapshot.pins)
710
+ snapshot.target ??= {
711
+ kind: "commits",
712
+ repositoryId: snapshot.pins.repositoryId,
713
+ base: snapshot.pins.base,
714
+ head: snapshot.pins.head,
715
+ };
716
+ const live = version === undefined ? this.liveSources.get(id) : undefined;
717
+
718
+ if (live?.version === snapshot.version) return structuredClone(live);
719
+
720
+ return snapshot;
721
+ }
722
+ setDiffStats(
723
+ pins: Pins,
724
+ stats: NonNullable<ReviewApiSummary["diffStats"]>,
725
+ mode: "structural" | "textual" = "structural",
726
+ ) {
727
+ if (this.closing) return;
728
+ this.db
729
+ .prepare(
730
+ "INSERT OR REPLACE INTO comparison_stats(identity, stats) VALUES (?, ?)",
731
+ )
732
+ .run(JSON.stringify([pins, mode]), JSON.stringify(stats));
733
+
734
+ for (const listener of this.catalogListeners) {
735
+ try {
736
+ listener();
737
+ } catch {
738
+ /* A disconnected viewer must not block other catalog subscribers. */
739
+ }
740
+ }
741
+ }
742
+
743
+ /** Managed records are discoverable even if their preparation stamp was lost. */
744
+ tutorialIds(): string[] {
745
+ return this.db
746
+ .prepare(
747
+ `SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version WHERE json_extract(versions.snapshot,'$.origin.tutorial') = 1`,
748
+ )
749
+ .all()
750
+ .map((row) => String(row.id));
751
+ }
752
+
753
+ list(mode: "structural" | "textual" = "structural"): ReviewApiSummary[] {
754
+ return this.summaries(mode);
755
+ }
756
+ /** One review's catalog entry, as review_list shows it. */
757
+ summary(id: string): ReviewApiSummary | undefined {
758
+ return this.summaries("structural", id)[0];
759
+ }
760
+ private summaries(
761
+ mode: "structural" | "textual",
762
+ id?: string,
763
+ ): ReviewApiSummary[] {
764
+ // One query, and the document never leaves SQLite: every catalog watcher
765
+ // re-lists on every command.
766
+
767
+ const reviews = this.db
768
+ .prepare(
769
+ `SELECT json_remove(versions.snapshot,'$.document') AS summary,
770
+ (SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS first_created_at,
771
+ review_attention.viewed_at, review_attention.dismissed_at, repositories.name AS repository_name, repositories.path AS repository_path
772
+ FROM reviews
773
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
774
+ LEFT JOIN review_attention ON review_attention.review_id=reviews.id
775
+ LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
776
+ WHERE COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
777
+ ${id === undefined ? "" : "AND reviews.id=?"}
778
+ ORDER BY reviews.rowid`,
779
+ )
780
+ .all(...(id === undefined ? [] : [id]))
781
+ .map((row) => {
782
+ // SAFETY: versions contains only snapshots validated by execute before committing.
783
+ const summary = JSON.parse(String(row.summary)) as Omit<
784
+ Snapshot,
785
+ "document"
786
+ >;
787
+
788
+ if (summary.pins)
789
+ summary.target ??= {
790
+ kind: "commits",
791
+ repositoryId: summary.pins.repositoryId,
792
+ base: summary.pins.base,
793
+ head: summary.pins.head,
794
+ };
795
+
796
+ const live = this.liveSources.get(summary.reviewId);
797
+
798
+ if (
799
+ live?.version === summary.version &&
800
+ summary.target?.kind === "worktree"
801
+ )
802
+ summary.pins = live.pins;
803
+
804
+ const listed: ReviewApiSummary = {
805
+ ...summary,
806
+ firstCreatedAt: row.first_created_at
807
+ ? String(row.first_created_at)
808
+ : undefined,
809
+ repositoryPath: row.repository_path
810
+ ? String(row.repository_path)
811
+ : undefined,
812
+ repositoryGroup: row.repository_path
813
+ ? this.repositoryGroup(String(row.repository_path))
814
+ : undefined,
815
+ repositoryName: row.repository_name
816
+ ? String(row.repository_name)
817
+ : (summary.pins?.repositoryId ?? ""),
818
+ viewedAt: row.viewed_at ? String(row.viewed_at) : null,
819
+ dismissedAt: row.dismissed_at ? String(row.dismissed_at) : null,
820
+ working: this.activity.isWorking(summary.reviewId),
821
+ };
822
+
823
+ if (summary.kind === "scratchpad")
824
+ listed.contents = this.scratchpadContents();
825
+
826
+ return listed;
827
+ });
828
+
829
+ return this.withDiffStats(reviews, mode);
830
+ }
831
+
832
+ /** Local and imported summaries use the same persisted, mode-specific counts. */
833
+ withDiffStats<T extends ReviewApiSummary>(
834
+ reviews: T[],
835
+ mode: "structural" | "textual" = "structural",
836
+ ): T[] {
837
+ const stats = new Map(
838
+ this.db
839
+ .prepare("SELECT identity, stats FROM comparison_stats")
840
+ .all()
841
+ .map((row) => [
842
+ String(row.identity),
843
+ // SAFETY: comparison_stats is written only from the validated diff-stats contract.
844
+ JSON.parse(String(row.stats)) as NonNullable<
845
+ ReviewApiSummary["diffStats"]
846
+ >,
847
+ ]),
848
+ );
849
+
850
+ return reviews.map((review) => ({
851
+ ...review,
852
+ diffStats: review.pins
853
+ ? (stats.get(JSON.stringify([review.pins, mode])) ?? null)
854
+ : null,
855
+ }));
856
+ }
857
+
858
+ /** What the pad holds, for its Home card: blocks, and the diagrams among them. */
859
+ private scratchpadContents(): NonNullable<ReviewApiSummary["contents"]> {
860
+ const blocks = elements(this.read(SCRATCHPAD_ID).document).filter(
861
+ (element) => !isUnit(element),
862
+ );
863
+
864
+ return {
865
+ blocks: blocks.length,
866
+ diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length,
867
+ };
868
+ }
869
+ /** The one scratchpad, made on first use. A host that loses the race to
870
+ * another on the same home finds it made. */
871
+ async ensureScratchpad(): Promise<void> {
872
+ if (this.has(SCRATCHPAD_ID)) return;
873
+
874
+ try {
875
+ await this.execute({
876
+ operation: {
877
+ type: "create",
878
+ title: SCRATCHPAD_TITLE,
879
+ kind: "scratchpad",
880
+ },
881
+ });
882
+ } catch (error) {
883
+ if (!this.has(SCRATCHPAD_ID)) throw error;
884
+ }
885
+ }
886
+ history(id: string) {
887
+ return this.db
888
+ .prepare(
889
+ "SELECT version,json_extract(snapshot,'$.title') AS title,json_extract(snapshot,'$.createdAt') AS created_at FROM versions WHERE review_id=? ORDER BY version",
890
+ )
891
+ .all(id)
892
+ .map((row) => ({
893
+ version: Number(row.version),
894
+ title: String(row.title),
895
+ createdAt: String(row.created_at),
896
+ }));
897
+ }
898
+ inspect(id: string, targetId?: string, version?: number) {
899
+ const snapshot = this.read(id, version);
900
+
901
+ return inspectSnapshot(snapshot, targetId);
902
+ }
903
+ /** The host can seed a managed document; transport callers only supply a command. */
904
+ execute(
905
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
906
+ input: unknown,
907
+ initial?: { document: Block[]; origin: SnapshotOrigin },
908
+ ): Promise<Result> {
909
+ if (this.closing)
910
+ return Promise.reject(new Error("Review store is closing."));
911
+ const command = commandSchema.parse(input);
912
+
913
+ if (initial && command.operation.type !== "create")
914
+ throw new ReviewInputError("Initial content requires a create command.");
915
+ // Network and fetch time stay out of the write queue.
916
+ const pullRequest = this.startPullRequest(command);
917
+
918
+ pullRequest?.catch(() => {});
919
+
920
+ const run = this.pending.then(async () => {
921
+ const op = command.operation;
922
+
923
+ // The scratchpad is edited and restored like a review, and nothing else.
924
+ if (
925
+ op.type !== "create" &&
926
+ op.type !== "edit" &&
927
+ op.type !== "lens_edit" &&
928
+ op.type !== "restore" &&
929
+ this.read(op.reviewId).kind === "scratchpad"
930
+ )
931
+ throw new ReviewInputError(
932
+ "The scratchpad has no lifecycle, title or pins of its own.",
933
+ 409,
934
+ );
935
+
936
+ if (op.type === "create" && op.kind === "scratchpad") {
937
+ if (op.target)
938
+ throw new ReviewInputError("A scratchpad has no target of its own.");
939
+
940
+ if (this.has(SCRATCHPAD_ID))
941
+ throw new ReviewInputError("The scratchpad already exists.", 409);
942
+ } else if (op.type === "create") {
943
+ if (!op.target && !op.pullRequestUrl)
944
+ throw new ReviewInputError("Supply a target or a pullRequestUrl.");
945
+
946
+ if (op.repositoryId && op.target)
947
+ throw new ReviewInputError(
948
+ "A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.",
949
+ );
950
+
951
+ if (!op.title && op.target)
952
+ throw new ReviewInputError("Supply a title.");
953
+ }
954
+
955
+ const requestedTarget =
956
+ op.type === "create" || op.type === "set_target"
957
+ ? op.target
958
+ : undefined;
959
+
960
+ const fromPullRequest = await pullRequest;
961
+
962
+ const resolvedTarget = requestedTarget
963
+ ? await (this.providers.resolveTarget?.(requestedTarget) ??
964
+ namedCommits(requestedTarget))
965
+ : fromPullRequest;
966
+
967
+ if (requestedTarget && !resolvedTarget)
968
+ throw new ReviewInputError("Review targets are unavailable.");
969
+
970
+ if (
971
+ op.type === "create" &&
972
+ op.pullRequestUrl &&
973
+ op.reuseExisting !== false
974
+ ) {
975
+ const [found, ...others] = this.reviewsForPullRequest(
976
+ op.pullRequestUrl,
977
+ );
978
+
979
+ if (found) {
980
+ const result = this.existingReview(
981
+ found,
982
+ others,
983
+ resolvedTarget!.pins,
984
+ );
985
+
986
+ return result;
987
+ }
988
+ }
989
+
990
+ if (op.type === "delete") {
991
+ const result: Result = {
992
+ reviewId: op.reviewId,
993
+ version: this.read(op.reviewId).version,
994
+ deleted: true,
995
+ };
996
+
997
+ this.commitCommand(
998
+ result,
999
+ () => {
1000
+ for (const table of [
1001
+ "ask_conversations",
1002
+ "authoring_presences",
1003
+ "review_coverage",
1004
+ "review_attention",
1005
+ "versions",
1006
+ ])
1007
+ this.db
1008
+ .prepare(`DELETE FROM ${table} WHERE review_id=?`)
1009
+ .run(op.reviewId);
1010
+ this.db.prepare("DELETE FROM reviews WHERE id=?").run(op.reviewId);
1011
+ },
1012
+ () => this.assertMutation(op.reviewId, result.version),
1013
+ );
1014
+
1015
+ return result;
1016
+ }
1017
+
1018
+ if (op.type === "attention") {
1019
+ const result: Result = {
1020
+ reviewId: op.reviewId,
1021
+ version: this.read(op.reviewId).version,
1022
+ attention: true,
1023
+ };
1024
+
1025
+ this.commitCommand(result, () => {
1026
+ this.db
1027
+ .prepare(
1028
+ "INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
1029
+ )
1030
+ .run(op.reviewId);
1031
+
1032
+ if (op.action === "view")
1033
+ this.db
1034
+ .prepare(
1035
+ "UPDATE review_attention SET viewed_at=? WHERE review_id=?",
1036
+ )
1037
+ .run(new Date().toISOString(), op.reviewId);
1038
+ else
1039
+ this.db
1040
+ .prepare(
1041
+ "UPDATE review_attention SET dismissed_at=? WHERE review_id=?",
1042
+ )
1043
+ .run(
1044
+ op.action === "dismiss" ? new Date().toISOString() : null,
1045
+ op.reviewId,
1046
+ );
1047
+ });
1048
+
1049
+ return result;
1050
+ }
1051
+
1052
+ const id =
1053
+ op.type !== "create"
1054
+ ? op.reviewId
1055
+ : op.kind === "scratchpad"
1056
+ ? SCRATCHPAD_ID
1057
+ : randomUUID();
1058
+
1059
+ const previous = op.type === "create" ? undefined : this.read(id);
1060
+
1061
+ let snapshot: Snapshot =
1062
+ op.type === "create"
1063
+ ? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title)
1064
+ : structuredClone(previous!);
1065
+
1066
+ // Overlay state: a degraded read must not persist unavailability.
1067
+ delete snapshot.sourceUnavailable;
1068
+ // Each version describes only its own edit.
1069
+ delete snapshot.lastEdit;
1070
+
1071
+ let nextId = previous
1072
+ ? Number(
1073
+ this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id)!
1074
+ .next_id,
1075
+ )
1076
+ : 0;
1077
+
1078
+ let applied: Applied | undefined;
1079
+ let lensTarget: { targetId: string; type: "lens" } | undefined;
1080
+
1081
+ if (
1082
+ (op.type === "create" || op.type === "set_target") &&
1083
+ this.providers.headBranch
1084
+ ) {
1085
+ const pins = resolvedTarget?.pins ?? snapshot.pins;
1086
+
1087
+ if (pins) {
1088
+ const headRef =
1089
+ requestedTarget?.kind === "commits"
1090
+ ? requestedTarget.head
1091
+ : undefined;
1092
+
1093
+ const branch = await this.providers.headBranch(pins, headRef);
1094
+
1095
+ snapshot.origin = { ...snapshot.origin, branch };
1096
+ }
1097
+ }
1098
+
1099
+ switch (op.type) {
1100
+ case "create":
1101
+ if (initial) {
1102
+ snapshot.document = documentSchema.parse(initial.document);
1103
+ snapshot.origin = {
1104
+ ...snapshot.origin,
1105
+ ...structuredClone(initial.origin),
1106
+ };
1107
+
1108
+ for (const block of snapshot.document)
1109
+ assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
1110
+ }
1111
+
1112
+ setPullRequest(snapshot, op.pullRequestUrl);
1113
+ break;
1114
+ case "rename":
1115
+ snapshot.title = op.title;
1116
+ break;
1117
+ case "set_target":
1118
+ setPullRequest(
1119
+ snapshot,
1120
+ op.pullRequestUrl ??
1121
+ (op.pullRequestUrl === null ||
1122
+ snapshot.pins?.repositoryId !== resolvedTarget!.pins.repositoryId
1123
+ ? null
1124
+ : undefined),
1125
+ );
1126
+ snapshot.staleSources = [];
1127
+ snapshot.target = resolvedTarget!.target;
1128
+ snapshot.pins = resolvedTarget!.pins;
1129
+ break;
1130
+ case "restore":
1131
+ snapshot = this.read(id, op.version);
1132
+ delete snapshot.lastEdit;
1133
+ break;
1134
+ case "lens_edit": {
1135
+ if (snapshot.kind === "scratchpad")
1136
+ throw new ReviewInputError(
1137
+ "The scratchpad has no changes of its own to lens.",
1138
+ 409,
1139
+ );
1140
+
1141
+ const lenses = snapshot.lenses ?? [];
1142
+ const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
1143
+
1144
+ if (lenses.length) snapshot.lenses = lenses;
1145
+ else delete snapshot.lenses;
1146
+ lensTarget = { targetId: lens.id, type: "lens" };
1147
+ snapshot.lastEdit = {
1148
+ type: op.edit.type,
1149
+ targetId: lens.id,
1150
+ blockId: lens.id,
1151
+ kind: "lens",
1152
+ ...(op.edit.type === "update" && {
1153
+ fields: Object.keys(op.edit).filter(
1154
+ (key) => key !== "type" && key !== "targetId",
1155
+ ),
1156
+ }),
1157
+ };
1158
+ break;
1159
+ }
1160
+
1161
+ case "edit": {
1162
+ applied = applyEdit(
1163
+ snapshot.document,
1164
+ op.edit,
1165
+ (prefix) => `${prefix}-${++nextId}`,
1166
+ // The scratchpad is a running log: the newest thought goes on top.
1167
+ { placement: snapshot.kind === "scratchpad" ? "first" : "last" },
1168
+ );
1169
+
1170
+ snapshot.lastEdit = summarizeEdit(
1171
+ op.edit,
1172
+ applied,
1173
+ previous!.document,
1174
+ snapshot.document,
1175
+ );
1176
+
1177
+ if (snapshot.staleSources?.length) {
1178
+ const oldSources = new Map(
1179
+ sourceReferences(previous!.document).map((item) => [
1180
+ item.id,
1181
+ JSON.stringify(item.source),
1182
+ ]),
1183
+ );
1184
+
1185
+ const newSources = new Map(
1186
+ sourceReferences(snapshot.document).map((item) => [
1187
+ item.id,
1188
+ JSON.stringify(item.source),
1189
+ ]),
1190
+ );
1191
+
1192
+ snapshot.staleSources = snapshot.staleSources.filter(
1193
+ (id) =>
1194
+ oldSources.get(id) === newSources.get(id) && newSources.has(id),
1195
+ );
1196
+ }
1197
+
1198
+ break;
1199
+ }
1200
+ }
1201
+
1202
+ if (
1203
+ snapshot.target?.kind === "worktree" &&
1204
+ op.type !== "restore" &&
1205
+ !resolvedTarget
1206
+ ) {
1207
+ snapshot = await this.projectLiveSource(snapshot);
1208
+ }
1209
+
1210
+ // Component shapes were checked at entry (or when merging a field patch).
1211
+ // Check cross-references here; do not reparse the whole stored document.
1212
+ checkReferences(snapshot.document);
1213
+
1214
+ if (
1215
+ snapshot.pins &&
1216
+ (!previous ||
1217
+ JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))
1218
+ )
1219
+ await this.providers.validatePins(snapshot.pins);
1220
+
1221
+ const warnings = await this.validateExternal(
1222
+ snapshot,
1223
+ previous,
1224
+ op.type === "set_target",
1225
+ );
1226
+
1227
+ snapshot.version = previous ? previous.version + 1 : 0;
1228
+ snapshot.createdAt = new Date().toISOString();
1229
+
1230
+ const result: Result = {
1231
+ ...(op.type === "create" && { created: true }),
1232
+ reviewId: id,
1233
+ version: snapshot.version,
1234
+ targetId: applied?.targetId ?? lensTarget?.targetId,
1235
+ ...(applied && { type: applied.type }),
1236
+ ...(lensTarget && { type: lensTarget.type }),
1237
+ ...(applied?.children && { children: applied.children }),
1238
+ };
1239
+
1240
+ if (snapshot.staleSources?.length)
1241
+ warnings.push(
1242
+ "Some authored source ranges changed. Update their references before presenting this review.",
1243
+ );
1244
+
1245
+ if (warnings.length) result.warnings = warnings;
1246
+
1247
+ this.commitCommand(
1248
+ result,
1249
+ () => {
1250
+ this.db
1251
+ .prepare(
1252
+ "INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id",
1253
+ )
1254
+ .run(id, snapshot.version, nextId);
1255
+ this.db
1256
+ .prepare(
1257
+ "INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)",
1258
+ )
1259
+ .run(id, snapshot.version, JSON.stringify(snapshot));
1260
+ },
1261
+ previous ? () => this.assertMutation(id, previous.version) : undefined,
1262
+ op.type === "edit" || op.type === "lens_edit"
1263
+ ? {
1264
+ surface: op.type === "edit" ? "document" : "lenses",
1265
+ activityId: op.activityId,
1266
+ // The version names the agent whose courier draws it.
1267
+ owned: (owner) => {
1268
+ if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
1269
+ },
1270
+ }
1271
+ : undefined,
1272
+ );
1273
+
1274
+ return result;
1275
+ });
1276
+
1277
+ this.pending = run.catch(() => {});
1278
+
1279
+ return run;
1280
+ }
1281
+ /** Resolve a target-less PR create before it queues, when it will need it. */
1282
+ private startPullRequest(
1283
+ command: z.infer<typeof commandSchema>,
1284
+ ): Promise<ResolvedPullRequest> | undefined {
1285
+ const op = command.operation;
1286
+
1287
+ if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl)
1288
+ return undefined;
1289
+
1290
+ if (!this.providers.resolvePullRequest)
1291
+ return Promise.reject(
1292
+ new ReviewInputError("Pull request targets are unavailable."),
1293
+ );
1294
+
1295
+ // Keep an existing review's checkout so headMoved compares like with like.
1296
+ const [existing] =
1297
+ op.reuseExisting === false
1298
+ ? []
1299
+ : this.reviewsForPullRequest(op.pullRequestUrl);
1300
+
1301
+ return this.providers.resolvePullRequest(op.pullRequestUrl, {
1302
+ id: op.repositoryId,
1303
+ preferred: existing && this.read(existing).pins?.repositoryId,
1304
+ });
1305
+ }
1306
+ /** Reviews whose PR is this one, newest version first. Summaries only. */
1307
+ private reviewsForPullRequest(url: string): string[] {
1308
+ return this.db
1309
+ .prepare(
1310
+ `SELECT reviews.id FROM reviews
1311
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
1312
+ WHERE lower(json_extract(versions.snapshot,'$.origin.pullRequestUrl'))=?
1313
+ AND COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
1314
+ ORDER BY json_extract(versions.snapshot,'$.createdAt') DESC, reviews.rowid DESC`,
1315
+ )
1316
+ .all(pullRequestKey(url))
1317
+ .map((row) => String(row.id));
1318
+ }
1319
+ /** The answer to a create that found its PR's review. Its target stays:
1320
+ * moving it would silently point existing links at different code. */
1321
+ private existingReview(
1322
+ reviewId: string,
1323
+ others: string[],
1324
+ requested: Pins,
1325
+ ): Result {
1326
+ const snapshot = this.read(reviewId);
1327
+
1328
+ const headMoved =
1329
+ snapshot.pins?.repositoryId !== requested.repositoryId ||
1330
+ snapshot.pins?.head !== requested.head;
1331
+
1332
+ const working = this.activity.read(reviewId).activities ?? [];
1333
+
1334
+ const note = [
1335
+ "Returned the existing review for this PR instead of creating one; the requested title and target were not applied. Update it in place (read it with session_get first), or pass reuseExisting:false to create a separate review.",
1336
+ headMoved &&
1337
+ "The PR head moved since this review's target was set, and the target was NOT changed: call review_set_target to move it, then repair the source references it reports.",
1338
+ working.length > 0 &&
1339
+ `Another agent is working on it${working[0]!.focus ? `: ${working[0]!.focus.description}` : ""}.`,
1340
+ others.length > 0 &&
1341
+ "Older reviews also name this PR; see otherReviewIds.",
1342
+ ]
1343
+ .filter(Boolean)
1344
+ .join(" ");
1345
+
1346
+ return {
1347
+ created: false,
1348
+ note,
1349
+ reviewId,
1350
+ version: snapshot.version,
1351
+ target: snapshot.target,
1352
+ headMoved,
1353
+ ...(working.length > 0 && { working: true }),
1354
+ ...(others.length > 0 && { otherReviewIds: others }),
1355
+ };
1356
+ }
1357
+ private commitCommand(
1358
+ result: Result,
1359
+ apply: () => void,
1360
+ guard?: () => void,
1361
+ /** An edit credits the agent it came from, renewing its presence. */
1362
+ attribution?: {
1363
+ surface: ActivitySurface;
1364
+ activityId?: string;
1365
+ owned(activityId: string): void;
1366
+ },
1367
+ ) {
1368
+ this.db.exec("BEGIN IMMEDIATE");
1369
+ let owner: string | undefined;
1370
+
1371
+ try {
1372
+ guard?.();
1373
+
1374
+ if (attribution) {
1375
+ owner = this.activity.attribute(
1376
+ result.reviewId,
1377
+ attribution.surface,
1378
+ attribution.activityId,
1379
+ );
1380
+
1381
+ if (owner) attribution.owned(owner);
1382
+ else if (attribution.activityId)
1383
+ result.warnings = [
1384
+ ...(result.warnings ?? []),
1385
+ "Your activity has ended or expired, so readers no longer see you working. The edit was saved; call session_activity_begin and pass the new activityId.",
1386
+ ];
1387
+ }
1388
+
1389
+ apply();
1390
+ this.db.exec("COMMIT");
1391
+ } catch (error) {
1392
+ this.db.exec("ROLLBACK");
1393
+ throw error;
1394
+ }
1395
+
1396
+ if (result.deleted) this.activity.deleted(result.reviewId);
1397
+ else if (owner) this.activity.extended(result.reviewId);
1398
+ this.notify(result);
1399
+ }
1400
+ private assertMutation(reviewId: string, version: number | undefined) {
1401
+ const current = this.db
1402
+ .prepare("SELECT version FROM reviews WHERE id=?")
1403
+ .get(reviewId);
1404
+
1405
+ if ((current ? Number(current.version) : undefined) !== version)
1406
+ throw new ReviewInputError(
1407
+ "Review changed during validation. Reread it and retry the edit.",
1408
+ 409,
1409
+ );
1410
+ }
1411
+
1412
+ private notify(result: Result) {
1413
+ if (result.deleted) this.observedVersions.delete(result.reviewId);
1414
+ else if (!result.attention)
1415
+ this.observedVersions.set(result.reviewId, result.version);
1416
+
1417
+ if (!result.attention)
1418
+ for (const listener of this.listeners)
1419
+ try {
1420
+ listener(result);
1421
+ } catch {
1422
+ // A subscriber failure must not reject the committed command.
1423
+ }
1424
+
1425
+ for (const listener of this.catalogListeners)
1426
+ try {
1427
+ listener();
1428
+ } catch {
1429
+ // The saved command must remain successful if a viewer disconnects.
1430
+ }
1431
+ }
1432
+ /** Dismissed reviews, as Home lists them; only a restore clears it, not a view. */
1433
+ dismissedIds(): string[] {
1434
+ return this.db
1435
+ .prepare(
1436
+ "SELECT review_id FROM review_attention WHERE dismissed_at IS NOT NULL",
1437
+ )
1438
+ .all()
1439
+ .map((row) => String(row.review_id));
1440
+ }
1441
+ has(reviewId: string): boolean {
1442
+ return (
1443
+ this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !==
1444
+ undefined
1445
+ );
1446
+ }
1447
+ private async validateExternal(
1448
+ snapshot: Snapshot,
1449
+ previous?: Snapshot,
1450
+ repin = false,
1451
+ ) {
1452
+ const warnings: string[] = [];
1453
+
1454
+ const references = (
1455
+ document: Block[],
1456
+ tolerant = false,
1457
+ lenses: readonly Lens[] = [],
1458
+ ) => {
1459
+ const sources = new Map<
1460
+ string,
1461
+ { source: FileLineRange; peek: boolean }
1462
+ >();
1463
+
1464
+ const resources = new Map<string, Block>();
1465
+
1466
+ const add = (source: FileLineRange, peek: boolean) => {
1467
+ const key = JSON.stringify(source);
1468
+ const kept = sources.get(key);
1469
+ sources.set(key, { source, peek: peek || (kept?.peek ?? false) });
1470
+ };
1471
+
1472
+ for (const { source, peek } of sourceReferences(document, { tolerant }))
1473
+ add(source, peek === true);
1474
+
1475
+ // A lens range is a prose-like link: it must exist, not read as a peek.
1476
+ for (const { source } of lensSelections(lenses))
1477
+ for (const anchor of sourceAnchors(source)) add(anchor, false);
1478
+
1479
+ for (const block of resourceReferences(document))
1480
+ resources.set(JSON.stringify(block), block);
1481
+
1482
+ return { sources, resources };
1483
+ };
1484
+
1485
+ const current = references(snapshot.document, repin, snapshot.lenses);
1486
+
1487
+ // Stored content is not re-validated: an edit may fix a link that the
1488
+ // current rules reject.
1489
+ const pinsChanged =
1490
+ previous &&
1491
+ JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
1492
+
1493
+ const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
1494
+
1495
+ const changed =
1496
+ repin && pinsChanged && previous.pins && snapshot.pins
1497
+ ? await this.providers
1498
+ .filesChangedBetween?.(previous.pins, snapshot.pins)
1499
+ .catch(() => undefined)
1500
+ : undefined;
1501
+
1502
+ const retained = references(
1503
+ previous?.document ?? [],
1504
+ true,
1505
+ previous?.lenses,
1506
+ );
1507
+
1508
+ // Independent reads of immutable commits: run them concurrently.
1509
+ const checks: Promise<void>[] = [];
1510
+
1511
+ // Pins a reference names itself must be resolved commits of a registered
1512
+ // repository, like document pins. Check each distinct new set once.
1513
+ const retainedPins = new Set(
1514
+ explicitPins([...retained.sources.values()]).map((pins) =>
1515
+ JSON.stringify(pins),
1516
+ ),
1517
+ );
1518
+
1519
+ for (const pins of explicitPins([...current.sources.values()]))
1520
+ if (!retainedPins.has(JSON.stringify(pins)))
1521
+ checks.push(this.providers.validatePins(pins));
1522
+
1523
+ for (const [key, { source, peek }] of current.sources) {
1524
+ const kept = retained.sources.get(key);
1525
+
1526
+ // A range validated earlier as a prose link still needs the peek check
1527
+ // the first time a code peek points at it. A reference with its own
1528
+ // pins is unaffected by the document's pins changing.
1529
+ if ((pinsChanged && !source.pins) || !kept || (peek && !kept.peek))
1530
+ checks.push(
1531
+ this.providers
1532
+ .validateSource(anchorPins(source, snapshot.pins), source, {
1533
+ peek,
1534
+ })
1535
+ .then(
1536
+ () => {
1537
+ if (
1538
+ repin &&
1539
+ (!changed || changed[source.side].has(source.file))
1540
+ )
1541
+ warnings.push(
1542
+ `${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: source pins changed; verify that this range still supports the document.`,
1543
+ );
1544
+ },
1545
+ (error) => {
1546
+ if (
1547
+ (!repin && !(worktreeMoved && kept)) ||
1548
+ !(error instanceof ReviewInputError)
1549
+ )
1550
+ throw error;
1551
+ warnings.push(
1552
+ `${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`,
1553
+ );
1554
+ },
1555
+ ),
1556
+ );
1557
+ }
1558
+
1559
+ for (const [key, block] of current.resources)
1560
+ if (pinsChanged || !retained.resources.has(key))
1561
+ checks.push(
1562
+ this.providers
1563
+ .validateResource(snapshot.pins, block)
1564
+ .catch((error) => {
1565
+ if (
1566
+ (!repin && !(worktreeMoved && retained.resources.has(key))) ||
1567
+ !(error instanceof ReviewInputError)
1568
+ )
1569
+ throw error;
1570
+ warnings.push(`${block.id} (${block.type}): ${error.message}`);
1571
+ }),
1572
+ );
1573
+
1574
+ await Promise.all(checks);
1575
+
1576
+ return warnings.sort();
1577
+ }
1578
+ }
1579
+
1580
+ /** Without a resolver, a commits target that names both commits pins them as
1581
+ * given; validatePins still checks they exist. */
1582
+ function namedCommits(target: ReviewTarget) {
1583
+ if (target.kind !== "commits" || target.base === undefined) return undefined;
1584
+
1585
+ const { repositoryId, base, head } = target;
1586
+
1587
+ return { target, pins: { repositoryId, base, head } };
1588
+ }
1589
+
1590
+ /** A new document's first version, before its initial content. Field order
1591
+ * is kept so stored JSON reads as it always has. */
1592
+ function createdSnapshot(
1593
+ id: string,
1594
+ op: { title?: string; kind?: "scratchpad" },
1595
+ resolved: { target: ReviewTarget; pins: Pins } | undefined,
1596
+ defaultTitle?: string,
1597
+ ): Snapshot {
1598
+ const title = op.title ?? defaultTitle;
1599
+
1600
+ if (!title) throw new ReviewInputError("Supply a title.");
1601
+
1602
+ if (!resolved)
1603
+ return {
1604
+ reviewId: id,
1605
+ version: 0,
1606
+ title,
1607
+ kind: op.kind,
1608
+ document: [],
1609
+ createdAt: "",
1610
+ };
1611
+
1612
+ return {
1613
+ reviewId: id,
1614
+ version: 0,
1615
+ title,
1616
+ pins: resolved.pins,
1617
+ target: resolved.target,
1618
+ document: [],
1619
+ createdAt: "",
1620
+ };
1621
+ }
1622
+
1623
+ export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
1624
+ if (targetId !== undefined) {
1625
+ const target = elements(snapshot.document).find(
1626
+ (element) => element.id === targetId,
1627
+ );
1628
+
1629
+ if (!target)
1630
+ throw new ReviewInputError("Target not found in this version.", 404);
1631
+
1632
+ return target;
1633
+ }
1634
+
1635
+ return elements(snapshot.document).map((element) => ({
1636
+ id: element.id,
1637
+ type: element.type,
1638
+ label:
1639
+ "title" in element
1640
+ ? element.title
1641
+ : "label" in element
1642
+ ? element.label
1643
+ : element.type === "markdown"
1644
+ ? element.markdown.slice(0, 120)
1645
+ : undefined,
1646
+ }));
1647
+ }