@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,4491 @@
1
+ import { _ as readFileAtCommit, b as resolveRevision, c as diffTrees, f as gitCommonDir, g as parseGitRemote, h as listTrackedFilesAtCommit, i as diff, l as diffWorkingTree, m as listCommitRange, n as createBlobBatchReader, o as diffFileSummariesTrees, p as jjRevisionIsConflicted, r as detectLocalVcs, s as diffFileSummariesWorkingTree, t as LocalVcsToolsMissingError, u as git, v as resolveRepoContext, y as resolveRepoContextSync } from "./src-CmdiBL20.js";
2
+ import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
3
+ import { _ as SCRATCHPAD_REVIEW_ID, b as structuralChangeCounts, g as ReviewSelectedDiffSchema, m as ReviewApiSelectionSourceSchema, y as decodeStructuralDiffEvent } from "./src-DnwdaQ2r.js";
4
+ import { $ as unionIntervals, A as applyLensEdit, C as resourceReferences, D as summarizeEdit, E as sourceReferences, F as checkSourcePath, I as fileLineRangeSchema, K as sourceAnchors, L as requireVisibleSource, N as lensSelections, O as checkReferences, P as SourceRangeError, Q as subtractIntervals, R as sliceSourceRange, S as resourceReference, X as intersectIntervals, Y as emptyCoverage, a as defineSoftwareMap, b as pinsSchema, c as traceSchema, d as applyEdit, et as updateCoverage, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, j as lensEditSchema, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, p as documentSchema, q as coverageSchema, r as migrateStoredLenses, t as liftFileLenses, u as anchorPins, v as isUnit, w as reviewTargetSchema } from "./stored-document-migration-DHeMpLMb.js";
5
+ import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
6
+ import { D as processIsAlive, O as withFileLock, P as writePrivateJsonAtomic } from "./s3-config-CAxhO9u_.js";
7
+ import { t as errorMessage } from "./error-message-OtiDonty.js";
8
+ import { s as safeStorageSegment } from "./review-home-paths-6zZH-1c9.js";
9
+ import { t as findReviewPackageRoot } from "./package-paths-B6-zxvIO.js";
10
+ import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
11
+ import { createRequire } from "node:module";
12
+ import crypto, { createHash, randomUUID } from "node:crypto";
13
+ import { z } from "zod";
14
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, watch } from "node:fs";
15
+ import * as fs$1 from "node:fs/promises";
16
+ import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
17
+ import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
18
+ import os from "node:os";
19
+ import { execFile, spawn } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { createInterface } from "node:readline";
23
+ //#region ../review-protocol/src/source-alignment.ts
24
+ /** The 0-based, half-open line span a region touches. An end at column 0 does not touch its end line. */
25
+ function alignmentRegionLines(region) {
26
+ return {
27
+ start: region.start.line,
28
+ end: region.end.column === 0 ? region.end.line : region.end.line + 1
29
+ };
30
+ }
31
+ function alignmentLeaves(regions) {
32
+ const leaves = [];
33
+ const walk = (region) => {
34
+ if (region.kind === "leaf") leaves.push(region);
35
+ else for (const child of region.children) walk(child);
36
+ };
37
+ for (const region of regions ?? []) walk(region);
38
+ return leaves;
39
+ }
40
+ /** Monaco keeps a final empty line after a newline; the wire need not mention it. */
41
+ function monacoLineCount(source) {
42
+ return source ? source.text.split("\n").length : 0;
43
+ }
44
+ /**
45
+ * Zips the two sides' leaves by `alignment_id` into the full row table. Paired leaves
46
+ * yield rows line for line, in the same order on both sides; an unpaired leaf
47
+ * yields one-sided rows. Rows past the last leaf pair the trailing empty
48
+ * lines Monaco keeps.
49
+ */
50
+ function structuralRows(diff) {
51
+ const lhsLeaves = alignmentLeaves(diff.lhs?.regions);
52
+ const rhsLeaves = alignmentLeaves(diff.rhs?.regions);
53
+ const leftCount = monacoLineCount(diff.lhs);
54
+ const rightCount = monacoLineCount(diff.rhs);
55
+ const rows = [];
56
+ let left = 0, right = 0;
57
+ const push = (l, r) => {
58
+ rows.push([l, r]);
59
+ if (l !== null) left = l + 1;
60
+ if (r !== null) right = r + 1;
61
+ };
62
+ const oneSided = (leaf, side) => {
63
+ const lines = alignmentRegionLines(leaf);
64
+ for (let line = lines.start; line < lines.end; line++) push(side === 0 ? line : null, side === 1 ? line : null);
65
+ };
66
+ const rhsIndex = new Map(rhsLeaves.map((leaf, index) => [leaf.alignment_id, index]));
67
+ let cursor = 0;
68
+ for (const leaf of lhsLeaves) {
69
+ const partner = rhsIndex.get(leaf.alignment_id);
70
+ if (partner === void 0) {
71
+ oneSided(leaf, 0);
72
+ continue;
73
+ }
74
+ while (cursor < partner) oneSided(rhsLeaves[cursor++], 1);
75
+ const a = alignmentRegionLines(leaf);
76
+ const b = alignmentRegionLines(rhsLeaves[partner]);
77
+ for (let offset = 0; offset < a.end - a.start; offset++) push(a.start + offset, b.start + offset);
78
+ cursor = partner + 1;
79
+ }
80
+ while (cursor < rhsLeaves.length) oneSided(rhsLeaves[cursor++], 1);
81
+ while (left < leftCount || right < rightCount) rows.push([left < leftCount ? left++ : null, right < rightCount ? right++ : null]);
82
+ return rows;
83
+ }
84
+ //#endregion
85
+ //#region src/review-checkout-paths.ts
86
+ /**
87
+ * Root for Review-owned checkouts. A Review UUID identifies one subtree.
88
+ */
89
+ function reviewManagedCheckoutsDir(gitCommonDir) {
90
+ return path.join(gitCommonDir, "dev-fast", "reviews");
91
+ }
92
+ /** Return the checkout subtree owned by one Review UUID. */
93
+ function reviewManagedCheckoutRoot(gitCommonDir, reviewUuid) {
94
+ return path.join(reviewManagedCheckoutsDir(gitCommonDir), safeStorageSegment(reviewUuid));
95
+ }
96
+ /** A detached checkout owned by one Review revision and source role. */
97
+ function reviewManagedCheckoutDir(gitCommonDir, reviewUuid, role, commit) {
98
+ return path.join(reviewManagedCheckoutRoot(gitCommonDir, reviewUuid), role, safeStorageSegment(commit));
99
+ }
100
+ //#endregion
101
+ //#region src/review-prepare.ts
102
+ const PREPARE_LOCK_RETRY_MS = 250;
103
+ const PREPARE_LOCK_STALE_MS = 18e5;
104
+ const PREPARE_LOCK_TIMEOUT_MS = 1e3;
105
+ const PREPARE_LOCK_UNOWNED_GRACE_MS = 1e3;
106
+ const PREPARE_OUTPUT_TAIL_LINES = 20;
107
+ /** The marker file that records a completed prepare, beside the worktree. */
108
+ function reviewPrepareMarkerPath(checkoutPath) {
109
+ return `${checkoutPath}.prepared`;
110
+ }
111
+ /** The failure log written beside the worktree when a prepare command fails. */
112
+ function reviewPrepareLogPath(checkoutPath) {
113
+ return `${checkoutPath}.prepare-log`;
114
+ }
115
+ function reviewPrepareCommandsHash(commands) {
116
+ return crypto.createHash("sha256").update(JSON.stringify(commands)).digest("hex").slice(0, 16);
117
+ }
118
+ async function removeReviewPrepareArtifacts(checkoutPath) {
119
+ await rm(reviewPrepareMarkerPath(checkoutPath), { force: true });
120
+ await rm(reviewPrepareLogPath(checkoutPath), { force: true });
121
+ }
122
+ async function prepareReviewPinnedCheckout(input) {
123
+ if (input.commands.length === 0) return { prepared: false };
124
+ const markerPath = reviewPrepareMarkerPath(input.checkoutPath);
125
+ const expectedHash = reviewPrepareCommandsHash(input.commands);
126
+ if (await markerMatches(markerPath, expectedHash)) return { prepared: true };
127
+ const outcome = await withFileLock(`${input.checkoutPath}.prepare-lock`, {
128
+ retryMs: PREPARE_LOCK_RETRY_MS,
129
+ staleMs: PREPARE_LOCK_STALE_MS,
130
+ heartbeatMs: 5e3,
131
+ timeoutMs: PREPARE_LOCK_TIMEOUT_MS,
132
+ unownedGraceMs: PREPARE_LOCK_UNOWNED_GRACE_MS
133
+ }, async () => {
134
+ if (await markerMatches(markerPath, expectedHash)) return { prepared: true };
135
+ await rm(markerPath, { force: true });
136
+ const runCommand = input.runCommand ?? (async (command, cwd) => {
137
+ return {
138
+ exitCode: 0,
139
+ output: await runPrepareCommand(command, cwd, input.progress ?? (() => {}), input.signal ?? new AbortController().signal, input.timeoutMs)
140
+ };
141
+ });
142
+ for (const command of input.commands) {
143
+ const result = await runCommand(command, input.checkoutPath).catch((cause) => ({
144
+ exitCode: null,
145
+ output: cause instanceof Error ? cause.message : String(cause)
146
+ }));
147
+ if (result.exitCode !== 0) {
148
+ const exit = result.exitCode === null ? "no exit code" : `exit ${result.exitCode}`;
149
+ const logNote = await writePrepareFailureLog(input.checkoutPath, result.output);
150
+ input.warning?.(formatPrepareFailure({
151
+ commit: input.commit,
152
+ command,
153
+ exit,
154
+ output: result.output,
155
+ logNote
156
+ }));
157
+ return { prepared: false };
158
+ }
159
+ }
160
+ await rm(reviewPrepareLogPath(input.checkoutPath), { force: true });
161
+ await writeFile(markerPath, JSON.stringify({
162
+ commandsHash: expectedHash,
163
+ preparedAt: Date.now()
164
+ }), "utf8");
165
+ return { prepared: true };
166
+ });
167
+ if (!outcome.acquired) {
168
+ input.warning?.(`devfast.prepare skipped for commit ${input.commit.slice(0, 12)}: another process holds the prepare lock. Language features use the unprepared pinned checkout.`);
169
+ return { prepared: false };
170
+ }
171
+ return outcome.result;
172
+ }
173
+ async function writePrepareFailureLog(checkoutPath, output) {
174
+ const trimmed = output?.trimEnd();
175
+ if (!trimmed) return "";
176
+ const logPath = reviewPrepareLogPath(checkoutPath);
177
+ return writeFile(logPath, `${trimmed}\n`, "utf8").then(() => ` Full output: ${logPath}.`).catch(() => "");
178
+ }
179
+ function formatPrepareFailure(input) {
180
+ const base = `devfast.prepare failed for commit ${input.commit.slice(0, 12)} (${input.exit}): ${input.command}. Language features use the unprepared pinned checkout.`;
181
+ const output = input.output?.trimEnd();
182
+ if (!output) return base;
183
+ const lines = output.split("\n");
184
+ const tail = lines.slice(-20).join("\n");
185
+ return `${base} Last output:\n${lines.length > PREPARE_OUTPUT_TAIL_LINES ? "…\n" : ""}${tail}${input.logNote}`;
186
+ }
187
+ async function markerMatches(markerPath, expectedHash) {
188
+ return readFile(markerPath, "utf8").then((contents) => jsonObject(parseJsonText(contents))?.commandsHash === expectedHash).catch(() => false);
189
+ }
190
+ function runPrepareCommand(command, cwd, progress, signal, timeoutMs = 9e5) {
191
+ return new Promise((resolve, reject) => {
192
+ signal.throwIfAborted();
193
+ let output = "";
194
+ const child = spawn(command, {
195
+ cwd,
196
+ shell: true,
197
+ detached: process.platform !== "win32",
198
+ stdio: [
199
+ "ignore",
200
+ "pipe",
201
+ "pipe"
202
+ ]
203
+ });
204
+ let interrupted;
205
+ let force;
206
+ const kill = (signal) => {
207
+ try {
208
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
209
+ else child.kill(signal);
210
+ } catch {}
211
+ };
212
+ const stop = (reason) => {
213
+ interrupted = reason;
214
+ kill("SIGTERM");
215
+ force ??= setTimeout(() => kill("SIGKILL"), 2e3);
216
+ force.unref();
217
+ };
218
+ const abort = () => stop(/* @__PURE__ */ new Error("Project command interrupted by shutdown."));
219
+ signal.addEventListener("abort", abort, { once: true });
220
+ const timeout = setTimeout(() => stop(/* @__PURE__ */ new Error("Project preparation timed out.")), timeoutMs);
221
+ timeout.unref();
222
+ const append = (chunk) => {
223
+ output = (output + chunk.toString()).slice(-64e3);
224
+ progress(output);
225
+ };
226
+ child.stdout.on("data", append);
227
+ child.stderr.on("data", append);
228
+ child.on("error", reject);
229
+ child.on("close", (code) => {
230
+ clearTimeout(timeout);
231
+ clearTimeout(force);
232
+ signal.removeEventListener("abort", abort);
233
+ if (interrupted) reject(/* @__PURE__ */ new Error(`${interrupted.message}\n${output}`));
234
+ else if (code === 0) resolve(output);
235
+ else reject(/* @__PURE__ */ new Error(`Command exited with ${code}.\n${output}`));
236
+ });
237
+ });
238
+ }
239
+ //#endregion
240
+ //#region src/review-head-checkout.ts
241
+ async function ensureReviewPinnedCheckout(input) {
242
+ const target = await resolveReviewPinnedCheckout({
243
+ ...input,
244
+ role: input.role ?? "head"
245
+ });
246
+ if (!target) return null;
247
+ if (!(await withFileLock(`${target.checkoutPath}.prepare-lock`, {
248
+ retryMs: 20,
249
+ timeoutMs: 3e4,
250
+ staleMs: 12e4,
251
+ heartbeatMs: 5e3,
252
+ unownedGraceMs: 1e3
253
+ }, () => materializeReviewPinnedCheckout({
254
+ rootPath: input.rootPath,
255
+ ...target
256
+ }))).acquired) throw new Error(`Pinned checkout ${target.commit} is busy; retry the operation.`);
257
+ return target.checkoutPath;
258
+ }
259
+ async function resolveReviewPinnedCheckout(input) {
260
+ const commit = await resolveReviewPinnedCommit(input.rootPath, input.ref);
261
+ if (!commit) return null;
262
+ const commonDir = await gitCommonDir(input.rootPath);
263
+ if (!commonDir) return null;
264
+ return {
265
+ checkoutPath: reviewManagedCheckoutDir(commonDir, input.reviewUuid, input.role ?? "head", commit),
266
+ commit
267
+ };
268
+ }
269
+ async function materializeReviewPinnedCheckout(input) {
270
+ const { checkoutPath } = input;
271
+ if (await isWorktreeAt(input.rootPath, checkoutPath, input.commit)) return;
272
+ if (await jjRevisionIsConflicted(input.rootPath, input.commit)) throw new Error(`Whiteboard cannot pin conflicted revision ${input.commit.slice(0, 12)}: the jj change has unresolved conflicts. Resolve them (jj resolve), then re-pin the review.`);
273
+ await git(input.rootPath, [
274
+ "worktree",
275
+ "remove",
276
+ "--force",
277
+ checkoutPath
278
+ ], { allowFailure: true });
279
+ await git(input.rootPath, ["worktree", "prune"], { allowFailure: true });
280
+ rmSync(checkoutPath, {
281
+ recursive: true,
282
+ force: true
283
+ });
284
+ await removeReviewPrepareArtifacts(checkoutPath);
285
+ await mkdir(path.dirname(checkoutPath), { recursive: true });
286
+ await git(input.rootPath, [
287
+ "worktree",
288
+ "add",
289
+ "--detach",
290
+ checkoutPath,
291
+ input.commit
292
+ ]);
293
+ }
294
+ /**
295
+ * Remove every checkout one Review owns in a repository. Registrations go
296
+ * first, so an interrupted removal cannot leave a gutted tree that git still
297
+ * lists and a later ensure would reuse; an unregistered leftover is replaced
298
+ * by the next ensure. Each checkout is removed on its own, so one that cannot
299
+ * be deleted leaves the others removed; the error names every failure.
300
+ */
301
+ async function removeReviewManagedCheckouts(commonDir, reviewUuid) {
302
+ const root = reviewManagedCheckoutRoot(commonDir, reviewUuid);
303
+ if (!isInsideDirectory(root, reviewManagedCheckoutsDir(commonDir))) throw new Error(`Refusing to remove non-managed checkouts at ${root}.`);
304
+ const roots = [root, path.join(realPath(path.dirname(root)), path.basename(root))];
305
+ const errors = [];
306
+ const kept = /* @__PURE__ */ new Set();
307
+ const attempt = (work) => work.then(() => true, (error) => {
308
+ errors.push(errorMessage(error));
309
+ return false;
310
+ });
311
+ for (const worktree of await listRegisteredWorktrees(commonDir)) if (roots.some((dir) => isInsideDirectory(worktree.worktreePath, dir)) && !await attempt(git(commonDir, [
312
+ "worktree",
313
+ "remove",
314
+ "--force",
315
+ worktree.worktreePath
316
+ ]))) kept.add(realPath(worktree.worktreePath));
317
+ const entries = async (dir) => (await lstat(dir).catch(() => null))?.isDirectory() ? await readdir(dir, { withFileTypes: true }) : [];
318
+ for (const entry of await entries(root)) {
319
+ const dir = path.join(root, entry.name);
320
+ const checkouts = entry.isDirectory() && (entry.name === "base" || entry.name === "head") ? (await entries(dir)).map((child) => path.join(dir, child.name)) : [dir];
321
+ for (const checkout of checkouts) if (!kept.has(path.join(realPath(path.dirname(checkout)), path.basename(checkout)))) await attempt(rm(checkout, {
322
+ recursive: true,
323
+ force: true
324
+ }));
325
+ }
326
+ if (!errors.length) await rm(root, {
327
+ recursive: true,
328
+ force: true
329
+ });
330
+ await git(commonDir, ["worktree", "prune"], { allowFailure: true });
331
+ if (errors.length) throw new Error(errors.join("\n"));
332
+ }
333
+ async function resolveReviewPinnedCommit(rootPath, ref) {
334
+ const resolved = await resolveRevision(rootPath, ref).catch(() => null);
335
+ if (resolved) return resolved.commit;
336
+ const fromGitDir = await git(rootPath, [
337
+ "rev-parse",
338
+ "--verify",
339
+ `${ref}^{commit}`
340
+ ], { allowFailure: true }).catch(() => null);
341
+ if (!fromGitDir?.ok) return null;
342
+ return fromGitDir.stdout.trim() || null;
343
+ }
344
+ async function isWorktreeAt(rootPath, checkoutPath, commit) {
345
+ if (!existsSync(path.join(checkoutPath, ".git"))) return false;
346
+ const list = await git(rootPath, [
347
+ "worktree",
348
+ "list",
349
+ "--porcelain"
350
+ ], { allowFailure: true }).catch(() => null);
351
+ if (!list?.ok) return false;
352
+ return parseWorktreeList(list.stdout).some((entry) => path.resolve(entry.worktreePath) === path.resolve(checkoutPath) && entry.headCommit === commit);
353
+ }
354
+ function parseWorktreeList(output) {
355
+ const entries = [];
356
+ for (const block of output.split(/\n\n+/)) {
357
+ let worktreePath = null;
358
+ let headCommit = null;
359
+ for (const line of block.split("\n")) if (line.startsWith("worktree ")) worktreePath = line.slice(9);
360
+ else if (line.startsWith("HEAD ")) headCommit = line.slice(5);
361
+ if (worktreePath) entries.push({
362
+ worktreePath,
363
+ headCommit
364
+ });
365
+ }
366
+ return entries;
367
+ }
368
+ async function listRegisteredWorktrees(rootPath) {
369
+ const listed = await git(rootPath, [
370
+ "worktree",
371
+ "list",
372
+ "--porcelain"
373
+ ], { allowFailure: true }).catch(() => null);
374
+ return listed?.ok ? parseWorktreeList(listed.stdout) : [];
375
+ }
376
+ function realPath(filePath) {
377
+ return existsSync(filePath) ? realpathSync(filePath) : path.resolve(filePath);
378
+ }
379
+ function isInsideDirectory(filePath, directory) {
380
+ const relative = path.relative(directory, filePath);
381
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
382
+ }
383
+ //#endregion
384
+ //#region src/server/diffr-languages.ts
385
+ const exec$1 = promisify(execFile);
386
+ const versionSchema = z.object({ version: z.string() });
387
+ const stampSchema = z.object({
388
+ version: z.string(),
389
+ full: z.boolean()
390
+ });
391
+ const pending = /* @__PURE__ */ new Map();
392
+ function configuration(root) {
393
+ const installer = path.join(root, "bin/diffr-package/bin/fetch.mjs");
394
+ const { version } = versionSchema.parse(JSON.parse(readFileSync(path.join(root, "bin/diffr-package/package.json"), "utf8")));
395
+ const directory = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "diffr", version, `${process.platform}-${process.arch}`, "full");
396
+ return {
397
+ installer,
398
+ version,
399
+ directory,
400
+ binary: path.join(directory, process.platform === "win32" ? "diffr.exe" : "diffr")
401
+ };
402
+ }
403
+ function installedFullDiffr(root) {
404
+ try {
405
+ const { binary, directory, version } = configuration(root);
406
+ const stat = lstatSync(binary);
407
+ const stamp = stampSchema.parse(JSON.parse(readFileSync(path.join(directory, "diffr.stamp.json"), "utf8")));
408
+ if (stat.isFile() && (process.platform === "win32" || stat.mode & 73) && stamp.full && stamp.version === version) return binary;
409
+ } catch {}
410
+ }
411
+ function installFullDiffr(root = findReviewPackageRoot(import.meta.url)) {
412
+ if (process.env.REVIEW_DIFFR_BINARY) return Promise.reject(/* @__PURE__ */ new Error("An explicitly selected diffr executable is in use. Install its full edition separately."));
413
+ const { installer, directory } = configuration(root);
414
+ const current = pending.get(directory);
415
+ if (current) return current;
416
+ const task = exec$1(process.execPath, [
417
+ installer,
418
+ "--full",
419
+ "--required",
420
+ "--into",
421
+ directory
422
+ ], {
423
+ env: {
424
+ ...process.env,
425
+ ELECTRON_RUN_AS_NODE: "1"
426
+ },
427
+ timeout: 18e4
428
+ }).then(() => {}).finally(() => pending.delete(directory));
429
+ pending.set(directory, task);
430
+ return task;
431
+ }
432
+ //#endregion
433
+ //#region src/server/structural-diff.ts
434
+ function diffrExecutable(packageRoot = findReviewPackageRoot(import.meta.url)) {
435
+ if (process.env.REVIEW_DIFFR_BINARY) return process.env.REVIEW_DIFFR_BINARY;
436
+ const full = installedFullDiffr(packageRoot);
437
+ if (full) return full;
438
+ const bundled = path.join(packageRoot, "bin", process.platform === "win32" ? "diffr.exe" : "diffr");
439
+ return existsSync(bundled) ? bundled : "diffr";
440
+ }
441
+ function diffrMissingError() {
442
+ return /* @__PURE__ */ new Error(`Cannot find diffr at ${diffrExecutable()}. Whiteboard Desktop bundles it at bin/diffr under its runtime; in a checkout, run \`pnpm --filter @dev.fast/whiteboard ensure:diffr\` or install diffr on PATH, or set REVIEW_DIFFR_BINARY to its executable.`);
443
+ }
444
+ /**
445
+ * Stream validated diffr records. File errors and aborted completion are data;
446
+ * launch failures, malformed/truncated streams and unexpected exits throw.
447
+ * Closing the iterator (including a consumer's break) terminates the child.
448
+ */
449
+ async function* structuralDiff(input) {
450
+ input.signal.throwIfAborted();
451
+ const comparison = input.comparison;
452
+ const args = [
453
+ "--repo",
454
+ input.repositoryPath,
455
+ "--format",
456
+ "ndjson",
457
+ "--stream-annotations"
458
+ ];
459
+ switch (comparison.kind) {
460
+ case "worktree":
461
+ args.push(comparison.base);
462
+ break;
463
+ case "trees":
464
+ args.push(comparison.base, comparison.head);
465
+ break;
466
+ case "merge-base": args.push(`${comparison.base}...${comparison.head}`);
467
+ }
468
+ args.push("--", ...input.paths ?? []);
469
+ const idleAbort = new AbortController();
470
+ const idle = setTimeout(() => idleAbort.abort(), 12e4);
471
+ const signal = AbortSignal.any([idleAbort.signal, input.signal]);
472
+ console.info(`[Review] structural diff: ${diffrExecutable()} ${args.join(" ")}`);
473
+ const child = spawn(diffrExecutable(), args, {
474
+ cwd: input.repositoryPath,
475
+ stdio: [
476
+ "ignore",
477
+ "pipe",
478
+ "pipe"
479
+ ],
480
+ signal
481
+ });
482
+ let stderr = "";
483
+ child.stderr.setEncoding("utf8");
484
+ child.stderr.on("data", (chunk) => {
485
+ stderr = (stderr + chunk).slice(-16384);
486
+ });
487
+ const exited = new Promise((resolve, reject) => {
488
+ child.once("error", (error) => {
489
+ reject(error.code === "ENOENT" ? diffrMissingError() : error);
490
+ });
491
+ child.once("close", (code) => resolve(code));
492
+ });
493
+ const exitError = (code) => /* @__PURE__ */ new Error(`diffr exited with ${code}: ${stderr}`);
494
+ exited.catch(() => {});
495
+ let started = false;
496
+ let completed = false;
497
+ let aborted;
498
+ let failed = 0;
499
+ let annotationFailed = false;
500
+ const lines = createInterface({
501
+ input: child.stdout,
502
+ crlfDelay: Infinity
503
+ });
504
+ try {
505
+ for await (const line of lines) {
506
+ idle.refresh();
507
+ if (Buffer.byteLength(line) > 67108864) throw new Error("Structural diff record exceeded 64 MiB.");
508
+ if (!line.trim()) continue;
509
+ const event = decodeStructuralDiffEvent(line);
510
+ if (completed) throw new Error("diffr emitted data after completion.");
511
+ if (!started) {
512
+ if (event.type !== "start" || event.version !== 4) throw new Error("Unsupported diffr stream protocol.");
513
+ started = true;
514
+ } else if (event.type === "complete") {
515
+ completed = true;
516
+ failed = event.failed;
517
+ aborted = event.aborted;
518
+ } else if (event.type === "annotations") annotationFailed ||= event.error !== void 0;
519
+ else if (event.type !== "file") throw new Error(`Unexpected diffr event: ${event.type}`);
520
+ yield event;
521
+ }
522
+ if (!completed) {
523
+ const code = await exited;
524
+ if (code !== 0) throw exitError(code);
525
+ throw new Error("diffr stream ended before completion.");
526
+ }
527
+ const code = await exited;
528
+ if (code !== 0 && !(code === 2 && (failed > 0 || annotationFailed || aborted !== void 0))) throw exitError(code);
529
+ } finally {
530
+ clearTimeout(idle);
531
+ lines.close();
532
+ if (child.exitCode === null) child.kill();
533
+ await exited.catch(() => {});
534
+ }
535
+ }
536
+ //#endregion
537
+ //#region src/server/structural-comparisons.ts
538
+ /** Replays a pinned comparison to concurrent rendering and coverage consumers. */
539
+ var StructuralComparisons = class {
540
+ entries = /* @__PURE__ */ new Map();
541
+ async *stream(input) {
542
+ input.signal.throwIfAborted();
543
+ const key = JSON.stringify([
544
+ configGeneration,
545
+ input.repositoryPath,
546
+ input.comparison,
547
+ input.paths
548
+ ]);
549
+ let entry = this.entries.get(key);
550
+ if (entry) this.entries.delete(key);
551
+ else entry = new Comparison(input);
552
+ this.entries.set(key, entry);
553
+ entry.readers++;
554
+ try {
555
+ yield* entry.read(input.signal);
556
+ } finally {
557
+ entry.readers--;
558
+ if (!entry.done && !entry.initialReady && !entry.readers || entry.error || entry.failed) {
559
+ entry.abort.abort();
560
+ this.entries.delete(key);
561
+ }
562
+ const idle = [...this.entries].filter(([, value]) => (value.done || value.initialReady) && !value.readers);
563
+ for (const [oldKey, old] of idle.slice(0, -10)) {
564
+ old.abort.abort();
565
+ this.entries.delete(oldKey);
566
+ }
567
+ }
568
+ }
569
+ close() {
570
+ for (const entry of this.entries.values()) entry.abort.abort();
571
+ this.entries.clear();
572
+ }
573
+ };
574
+ let configGeneration = 0;
575
+ function invalidateStructuralComparisons() {
576
+ configGeneration++;
577
+ }
578
+ var Comparison = class {
579
+ abort = new AbortController();
580
+ events = [];
581
+ readers = 0;
582
+ done = false;
583
+ error;
584
+ failed = false;
585
+ initialReady = false;
586
+ remaining;
587
+ listeners = /* @__PURE__ */ new Set();
588
+ constructor(input) {
589
+ this.run(input);
590
+ }
591
+ async run(input) {
592
+ try {
593
+ for await (const event of structuralDiff({
594
+ ...input,
595
+ signal: this.abort.signal
596
+ })) {
597
+ if (event.type === "start") {
598
+ this.remaining = new Set(event.files.map((entry) => (entry.file.rhs ?? entry.file.lhs).path));
599
+ this.initialReady = this.remaining.size === 0;
600
+ } else if (event.type === "file") {
601
+ const path = (event.file.rhs ?? event.file.lhs).path;
602
+ if (!this.remaining?.delete(path)) throw new Error(`Unexpected structural result: ${path}`);
603
+ this.initialReady = this.remaining.size === 0;
604
+ }
605
+ if (event.type === "complete") this.failed = event.failed > 0 || !!event.aborted;
606
+ this.events.push(event);
607
+ for (const wake of this.listeners) wake();
608
+ }
609
+ } catch (error) {
610
+ this.error = error;
611
+ } finally {
612
+ this.done = true;
613
+ for (const wake of this.listeners) wake();
614
+ }
615
+ }
616
+ async *read(signal) {
617
+ let index = 0;
618
+ while (true) {
619
+ signal.throwIfAborted();
620
+ if (index < this.events.length) {
621
+ yield this.events[index++];
622
+ continue;
623
+ }
624
+ if (this.done) {
625
+ if (this.error) throw this.error;
626
+ return;
627
+ }
628
+ await new Promise((resolve) => {
629
+ const wake = () => {
630
+ this.listeners.delete(wake);
631
+ signal.removeEventListener("abort", wake);
632
+ resolve();
633
+ };
634
+ this.listeners.add(wake);
635
+ signal.addEventListener("abort", wake, { once: true });
636
+ });
637
+ }
638
+ }
639
+ };
640
+ //#endregion
641
+ //#region src/unified-diff.ts
642
+ const HUNK_HEADER = /^@@ -(?<oldStart>\d+)(?:,(?<oldLines>\d+))? \+(?<newStart>\d+)(?:,(?<newLines>\d+))? @@/;
643
+ function parseUnifiedPatch(file, patch) {
644
+ if (!patch) return [];
645
+ const hunks = [];
646
+ let current = null;
647
+ let oldLine = 0;
648
+ let newLine = 0;
649
+ for (const row of patch.split(/\r?\n/)) {
650
+ const header = HUNK_HEADER.exec(row);
651
+ if (header?.groups) {
652
+ current = {
653
+ file,
654
+ oldStart: Number(header.groups.oldStart),
655
+ oldLines: Number(header.groups.oldLines ?? "1"),
656
+ newStart: Number(header.groups.newStart),
657
+ newLines: Number(header.groups.newLines ?? "1"),
658
+ lines: []
659
+ };
660
+ oldLine = current.oldStart;
661
+ newLine = current.newStart;
662
+ hunks.push(current);
663
+ continue;
664
+ }
665
+ if (!current || row.startsWith("\")) continue;
666
+ const marker = row[0];
667
+ const text = row.slice(1);
668
+ let line = null;
669
+ if (marker === " ") {
670
+ line = {
671
+ kind: "context",
672
+ oldLine,
673
+ newLine,
674
+ text
675
+ };
676
+ oldLine += 1;
677
+ newLine += 1;
678
+ } else if (marker === "+") {
679
+ line = {
680
+ kind: "add",
681
+ oldLine: null,
682
+ newLine,
683
+ text
684
+ };
685
+ newLine += 1;
686
+ } else if (marker === "-") {
687
+ line = {
688
+ kind: "remove",
689
+ oldLine,
690
+ newLine: null,
691
+ text
692
+ };
693
+ oldLine += 1;
694
+ }
695
+ if (line) current.lines.push(line);
696
+ }
697
+ return hunks;
698
+ }
699
+ //#endregion
700
+ //#region src/software-map-diff-counts.ts
701
+ async function resolveSoftwareMapDiffCounts(input) {
702
+ const baseRef = input.baseRef?.trim();
703
+ if (!baseRef) return {
704
+ countsByElementPath: {},
705
+ unmappedByElementPath: {}
706
+ };
707
+ const headRef = input.headRef?.trim() || void 0;
708
+ const sourceRootPath = input.sourceRootPath;
709
+ const diff$1 = input.patch ?? await diff({
710
+ rootPath: sourceRootPath,
711
+ kind: input.sourceVcsKind,
712
+ baseRef,
713
+ headRef,
714
+ contextLines: 0
715
+ }).catch(() => "");
716
+ if (!diff$1.trim()) return {
717
+ baseRef,
718
+ headRef,
719
+ countsByElementPath: {},
720
+ unmappedByElementPath: {}
721
+ };
722
+ const countsByFile = parseGitUnifiedDiffLineCounts(diff$1, input.side);
723
+ if (countsByFile.size === 0) return {
724
+ baseRef,
725
+ headRef,
726
+ countsByElementPath: {},
727
+ unmappedByElementPath: {}
728
+ };
729
+ const mapped = await mapDiffLineCountsToSoftwareMapElements({
730
+ codeElements: input.codeElements,
731
+ countsByFile
732
+ });
733
+ const unmappedByElementPath = mapDiffLineCountsToCoverageClaims({
734
+ coverageClaims: input.coverageClaims ?? [],
735
+ countsByFile
736
+ });
737
+ return {
738
+ baseRef,
739
+ headRef,
740
+ countsByElementPath: mapped.countsByElementPath,
741
+ unmappedByElementPath
742
+ };
743
+ }
744
+ function parseGitUnifiedDiffLineCounts(diff, side = "head") {
745
+ const countsByFile = /* @__PURE__ */ new Map();
746
+ for (const fileDiff of splitGitDiff(diff)) {
747
+ const file = side === "base" ? fileDiff.oldFile ?? fileDiff.newFile : fileDiff.newFile ?? fileDiff.oldFile;
748
+ if (!file) continue;
749
+ for (const hunk of parseUnifiedPatch(file, fileDiff.patch)) {
750
+ let currentNewLine = Math.max(1, hunk.newStart);
751
+ let currentOldLine = Math.max(1, hunk.oldStart);
752
+ let replacedOldLine = null;
753
+ for (const line of hunk.lines) {
754
+ if (line.kind === "add" && line.newLine !== null) {
755
+ addLineCount(countsByFile, file, side === "base" ? replacedOldLine ?? currentOldLine : line.newLine, "additions", line);
756
+ currentNewLine = line.newLine + 1;
757
+ continue;
758
+ }
759
+ if (line.kind === "remove") {
760
+ const oldLine = line.oldLine ?? currentOldLine;
761
+ addLineCount(countsByFile, file, side === "base" ? oldLine : currentNewLine, "deletions", line);
762
+ replacedOldLine ??= oldLine;
763
+ currentOldLine = oldLine + 1;
764
+ continue;
765
+ }
766
+ replacedOldLine = null;
767
+ if (line.newLine !== null) currentNewLine = line.newLine + 1;
768
+ if (line.oldLine !== null) currentOldLine = line.oldLine + 1;
769
+ }
770
+ }
771
+ }
772
+ return countsByFile;
773
+ }
774
+ function mapDiffLineCountsToSoftwareMapElements(input) {
775
+ const countsByElementPath = {};
776
+ for (const element of input.codeElements) {
777
+ const total = {
778
+ additions: 0,
779
+ deletions: 0
780
+ };
781
+ const countedLines = /* @__PURE__ */ new Set();
782
+ for (const range of element.sourceRanges ?? []) {
783
+ const fileCounts = input.countsByFile.get(range.file);
784
+ if (!fileCounts) continue;
785
+ for (const [line, counts] of fileCounts) {
786
+ if (line < range.fromLine || line > range.toLine) continue;
787
+ const lineKey = `${range.file}:${line}`;
788
+ if (countedLines.has(lineKey)) continue;
789
+ countedLines.add(lineKey);
790
+ total.additions += element.changeStatus === "removed" ? 0 : counts.additions;
791
+ total.deletions += element.changeStatus === "added" ? 0 : counts.deletions;
792
+ }
793
+ }
794
+ if (total.additions > 0 || total.deletions > 0) countsByElementPath[element.path] = total;
795
+ }
796
+ return { countsByElementPath };
797
+ }
798
+ function mapDiffLineCountsToCoverageClaims(input) {
799
+ const result = {};
800
+ const claims = input.coverageClaims.filter((claim) => (claim.files?.length ?? 0) > 0 || (claim.globs?.length ?? 0) > 0).sort(compareCoverageClaims);
801
+ for (const [file, fileCounts] of input.countsByFile) for (const [line, counts] of fileCounts) for (const claim of claims) {
802
+ if (!softwareMapCoverageClaimMatchesLine(claim, file, line)) continue;
803
+ const summary = result[claim.path] ??= {
804
+ additions: 0,
805
+ deletions: 0,
806
+ files: []
807
+ };
808
+ summary.additions += counts.additions;
809
+ summary.deletions += counts.deletions;
810
+ addUnmappedFileLine(summary, file, line, counts.rows);
811
+ }
812
+ return result;
813
+ }
814
+ function addLineCount(countsByFile, file, line, kind, diffLine) {
815
+ const fileCounts = countsByFile.get(file) ?? /* @__PURE__ */ new Map();
816
+ const current = fileCounts.get(line) ?? {
817
+ additions: 0,
818
+ deletions: 0,
819
+ rows: []
820
+ };
821
+ current[kind] += 1;
822
+ current.rows.push(toUnmappedDiffLine(diffLine));
823
+ fileCounts.set(line, current);
824
+ countsByFile.set(file, fileCounts);
825
+ }
826
+ function addUnmappedFileLine(summary, file, line, rows) {
827
+ let fileSummary = summary.files.find((candidate) => candidate.file === file);
828
+ if (!fileSummary) {
829
+ fileSummary = {
830
+ file,
831
+ additions: 0,
832
+ deletions: 0,
833
+ hunks: []
834
+ };
835
+ summary.files.push(fileSummary);
836
+ }
837
+ for (const row of rows) {
838
+ if (row.kind === "add") fileSummary.additions += 1;
839
+ if (row.kind === "remove") fileSummary.deletions += 1;
840
+ }
841
+ const lastHunk = fileSummary.hunks.at(-1);
842
+ if (lastHunk && line <= lastHunk.startLine + lastHunk.lines.length + 1) lastHunk.lines.push(...rows);
843
+ else fileSummary.hunks.push({
844
+ startLine: line,
845
+ lines: [...rows]
846
+ });
847
+ }
848
+ function toUnmappedDiffLine(line) {
849
+ return {
850
+ kind: line.kind === "add" ? "add" : "remove",
851
+ oldLine: line.oldLine,
852
+ newLine: line.newLine,
853
+ text: line.text
854
+ };
855
+ }
856
+ function compareCoverageClaims(left, right) {
857
+ return coverageSpecificity(right) - coverageSpecificity(left);
858
+ }
859
+ function coverageSpecificity(claim) {
860
+ const pathDepth = claim.path.split(".").length * 1e3;
861
+ const exactFiles = (claim.files?.length ?? 0) * 20;
862
+ const globs = claim.globs?.reduce((total, glob) => total + glob.length, 0) ?? 0;
863
+ return pathDepth + exactFiles + globs;
864
+ }
865
+ function softwareMapCoverageClaimMatchesLine(claim, file, line) {
866
+ for (const claimedFile of claim.files ?? []) {
867
+ if (claimedFile.path !== file) continue;
868
+ if (softwareMapLineInRanges(line, claimedFile.ranges)) return true;
869
+ }
870
+ return (claim.globs ?? []).some((glob) => softwareMapGlobMatches(glob, file));
871
+ }
872
+ function softwareMapLineInRanges(line, ranges = []) {
873
+ if (ranges.length === 0) return true;
874
+ return ranges.some((range) => line >= range.fromLine && line <= range.toLine);
875
+ }
876
+ const softwareMapGlobRegexes = /* @__PURE__ */ new Map();
877
+ function softwareMapGlobMatches(pattern, value) {
878
+ let regex = softwareMapGlobRegexes.get(pattern);
879
+ if (!regex) {
880
+ regex = new RegExp(`^${globToRegex(pattern)}$`);
881
+ softwareMapGlobRegexes.set(pattern, regex);
882
+ }
883
+ return regex.test(value);
884
+ }
885
+ function globToRegex(pattern) {
886
+ let result = "";
887
+ for (let index = 0; index < pattern.length; index += 1) {
888
+ const char = pattern[index];
889
+ const next = pattern[index + 1];
890
+ if (char === "*" && next === "*") {
891
+ result += ".*";
892
+ index += 1;
893
+ continue;
894
+ }
895
+ if (char === "*") {
896
+ result += "[^/]*";
897
+ continue;
898
+ }
899
+ if (char === "?") {
900
+ result += "[^/]";
901
+ continue;
902
+ }
903
+ result += escapeRegex(char);
904
+ }
905
+ return result;
906
+ }
907
+ function escapeRegex(char) {
908
+ return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
909
+ }
910
+ function splitGitDiff(diff) {
911
+ const result = [];
912
+ let current = null;
913
+ for (const line of diff.split(/\r?\n/)) {
914
+ if (line.startsWith("diff --git ")) {
915
+ if (current) result.push({
916
+ oldFile: current.oldFile,
917
+ newFile: current.newFile,
918
+ patch: current.lines.join("\n")
919
+ });
920
+ current = {
921
+ oldFile: null,
922
+ newFile: null,
923
+ lines: []
924
+ };
925
+ continue;
926
+ }
927
+ if (!current) continue;
928
+ const oldFile = parseGitFileLine(line, "--- ");
929
+ const newFile = parseGitFileLine(line, "+++ ");
930
+ if (oldFile !== void 0) {
931
+ current.oldFile = oldFile;
932
+ continue;
933
+ }
934
+ if (newFile !== void 0) {
935
+ current.newFile = newFile;
936
+ continue;
937
+ }
938
+ current.lines.push(line);
939
+ }
940
+ if (current) result.push({
941
+ oldFile: current.oldFile,
942
+ newFile: current.newFile,
943
+ patch: current.lines.join("\n")
944
+ });
945
+ return result;
946
+ }
947
+ function parseGitFileLine(line, prefix) {
948
+ if (!line.startsWith(prefix)) return void 0;
949
+ const raw = unquoteGitPath(line.slice(prefix.length).trim());
950
+ if (raw === "/dev/null") return null;
951
+ if (raw.startsWith("a/") || raw.startsWith("b/")) return raw.slice(2);
952
+ return raw;
953
+ }
954
+ function unquoteGitPath(value) {
955
+ if (!value.startsWith(`"`)) return value;
956
+ const unquoted = value.slice(1, value.endsWith(`"`) ? -1 : void 0);
957
+ try {
958
+ return jsonString(parseJsonText(value)) ?? unquoted;
959
+ } catch {
960
+ return unquoted;
961
+ }
962
+ }
963
+ //#endregion
964
+ //#region src/review-api/checkout-fs.ts
965
+ /**
966
+ * File system calls for paths inside a user's checkout. Electron's `fs`
967
+ * treats every `*.asar` path as an archive, so a checkout that merely
968
+ * contains one fails with "Invalid package" or shows it as a directory.
969
+ * Electron's unpatched `original-fs` reads it as the plain file it is.
970
+ */
971
+ const checkoutFs = process.versions.electron ? createRequire(import.meta.url)("original-fs").promises : fs$1;
972
+ //#endregion
973
+ //#region src/review-api/lens-alignment.ts
974
+ /** Project the already-computed patch's runs, including unchanged gaps. */
975
+ function textualRows(path, patch, baseCount, headCount) {
976
+ const rows = [];
977
+ let base = 0, head = 0;
978
+ const append = (baseEnd, headEnd) => {
979
+ while (base < baseEnd || head < headEnd) rows.push([base < baseEnd ? base++ : null, head < headEnd ? head++ : null]);
980
+ };
981
+ for (const hunk of parseUnifiedPatch(path, patch)) {
982
+ append(hunk.oldLines ? hunk.oldStart - 1 : hunk.oldStart, hunk.newLines ? hunk.newStart - 1 : hunk.newStart);
983
+ let removed = 0, added = 0;
984
+ const flush = () => {
985
+ append(base + removed, head + added);
986
+ removed = added = 0;
987
+ };
988
+ for (const line of hunk.lines) if (line.kind === "context") {
989
+ flush();
990
+ append(base + 1, head + 1);
991
+ } else if (line.kind === "add") added++;
992
+ else removed++;
993
+ flush();
994
+ }
995
+ append(baseCount, headCount);
996
+ return rows;
997
+ }
998
+ //#endregion
999
+ //#region src/review-api/comparison-coverage.ts
1000
+ const hash = (parts) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
1001
+ /** Immutable comparison facts shared by catalog totals and review progress. */
1002
+ async function comparisonCoverage(data, reviewId, pins, mode, signal, publish) {
1003
+ const fileSources = /* @__PURE__ */ new Map();
1004
+ const alignments = /* @__PURE__ */ new Map();
1005
+ const files = [];
1006
+ if (mode === "textual") for (const file of await data.changes(pins)) {
1007
+ signal.throwIfAborted();
1008
+ const patch = await data.changes(pins, file.path);
1009
+ const changed = emptyCoverage();
1010
+ for (const hunk of parseUnifiedPatch(file.path, patch)) for (const line of hunk.lines) {
1011
+ if (line.kind === "add") changed.head.push([line.newLine - 1, line.newLine]);
1012
+ if (line.kind === "remove") changed.base.push([line.oldLine - 1, line.oldLine]);
1013
+ }
1014
+ changed.base = unionIntervals(changed.base);
1015
+ changed.head = unionIntervals(changed.head);
1016
+ const [base, head] = await Promise.all([file.status === "added" ? null : data.file(pins, "base", file.previousPath ?? file.path).then((value) => value.text).catch(() => null), file.status === "deleted" ? null : data.file(pins, "head", file.path).then((value) => value.text).catch(() => null)]);
1017
+ alignments.set(file.path, textualRows(file.path, patch, base === null ? 0 : base.split("\n").length, head === null ? 0 : head.split("\n").length));
1018
+ const readable = (file.status === "added" || base !== null) && (file.status === "deleted" || head !== null);
1019
+ const fingerprint = hash([
1020
+ pins.repositoryId,
1021
+ base,
1022
+ head,
1023
+ ...readable ? [] : [
1024
+ pins.base,
1025
+ pins.head,
1026
+ patch
1027
+ ]
1028
+ ]);
1029
+ fileSources.set(file.path, [...base !== null ? [{
1030
+ side: "base",
1031
+ file: file.previousPath ?? file.path,
1032
+ fromLine: 1,
1033
+ toLine: base.split("\n").length
1034
+ }] : [], ...head !== null ? [{
1035
+ side: "head",
1036
+ file: file.path,
1037
+ fromLine: 1,
1038
+ toLine: head.split("\n").length
1039
+ }] : []]);
1040
+ files.push({
1041
+ path: file.path,
1042
+ previousPath: file.previousPath,
1043
+ fingerprint,
1044
+ changed,
1045
+ viewed: emptyCoverage()
1046
+ });
1047
+ publish?.({
1048
+ files: [...files],
1049
+ fileSources: new Map(fileSources),
1050
+ alignments: new Map(alignments)
1051
+ });
1052
+ }
1053
+ else {
1054
+ const remaining = /* @__PURE__ */ new Set();
1055
+ for await (const event of data.structuralChanges({
1056
+ reviewId,
1057
+ pins,
1058
+ signal
1059
+ })) {
1060
+ if (event.type === "start") {
1061
+ for (const entry of event.files) remaining.add((entry.file.rhs ?? entry.file.lhs).path);
1062
+ if (!remaining.size) break;
1063
+ continue;
1064
+ }
1065
+ if (event.type === "complete" && (event.failed || event.aborted)) throw new Error(event.aborted?.message ?? "Structural coverage is incomplete.");
1066
+ if (event.type !== "file") continue;
1067
+ const path = (event.file.rhs ?? event.file.lhs).path;
1068
+ if (!remaining.delete(path)) throw new Error(`Unexpected structural result: ${path}`);
1069
+ if (event.error) {
1070
+ if (event.error.code !== "unsupported_file_type") throw new Error(`Cannot count ${path}: ${event.error.message}`);
1071
+ if (!remaining.size) break;
1072
+ continue;
1073
+ }
1074
+ const diff = event.diff;
1075
+ const previousPath = event.file.lhs?.path !== path ? event.file.lhs?.path : void 0;
1076
+ const base = diff.type === "text" ? diff.lhs?.text ?? null : null;
1077
+ const head = diff.type === "text" ? diff.rhs?.text ?? null : null;
1078
+ const fingerprint = hash([
1079
+ pins.repositoryId,
1080
+ base,
1081
+ head,
1082
+ ...diff.type === "binary" ? [event.file.lhs?.oid ?? null, event.file.rhs?.oid ?? null] : []
1083
+ ]);
1084
+ const sources = [];
1085
+ if (base !== null) sources.push({
1086
+ side: "base",
1087
+ file: event.file.lhs.path,
1088
+ fromLine: 1,
1089
+ toLine: base.split("\n").length
1090
+ });
1091
+ if (head !== null) sources.push({
1092
+ side: "head",
1093
+ file: path,
1094
+ fromLine: 1,
1095
+ toLine: head.split("\n").length
1096
+ });
1097
+ fileSources.set(path, sources);
1098
+ if (diff.type === "text") alignments.set(path, structuralRows(diff));
1099
+ files.push({
1100
+ path,
1101
+ previousPath,
1102
+ fingerprint,
1103
+ changed: diff.type === "text" ? diff.structural_changes : emptyCoverage(),
1104
+ folded: foldedChanges(event.visibility, diff),
1105
+ viewed: emptyCoverage()
1106
+ });
1107
+ publish?.({
1108
+ files: [...files],
1109
+ fileSources: new Map(fileSources),
1110
+ alignments: new Map(alignments)
1111
+ });
1112
+ if (!remaining.size) break;
1113
+ }
1114
+ if (remaining.size) throw new Error("Structural coverage is incomplete.");
1115
+ }
1116
+ return {
1117
+ files,
1118
+ fileSources,
1119
+ alignments
1120
+ };
1121
+ }
1122
+ /**
1123
+ * The changed lines diffr folds by default. A file it hides (lockfiles,
1124
+ * generated, vendored and test files, per its plugins) folds all of them.
1125
+ * Otherwise they are the changed lines under regions that start collapsed
1126
+ * and in no visible leaf: `structural_changes` less what diffr counts in
1127
+ * `stats.visible`, recomputed the way diffr's `change_coverage` does so that
1128
+ * the lines, not just the counts, are known. A paired leaf contributes its
1129
+ * changed spans' lines; an unpaired leaf, all of its lines.
1130
+ */
1131
+ function foldedChanges(visibility, diff) {
1132
+ if (diff.type !== "text") return emptyCoverage();
1133
+ const all = diff.structural_changes;
1134
+ if (visibility?.collapsed) return {
1135
+ base: unionIntervals(all.base),
1136
+ head: unionIntervals(all.head)
1137
+ };
1138
+ const side = (changed, source, other) => {
1139
+ const paired = /* @__PURE__ */ new Set();
1140
+ const pair = (region) => {
1141
+ if (region.kind === "leaf") paired.add(region.alignment_id);
1142
+ else region.children.forEach(pair);
1143
+ };
1144
+ other?.regions?.forEach(pair);
1145
+ const hidden = [], visible = [];
1146
+ const collect = (region, folded) => {
1147
+ folded ||= region.visibility?.collapsed === true;
1148
+ if (region.kind === "fold") {
1149
+ for (const child of region.children) collect(child, folded);
1150
+ return;
1151
+ }
1152
+ const lines = folded ? hidden : visible;
1153
+ if (paired.has(region.alignment_id)) for (const span of region.changed ?? []) lines.push([span.line, span.line + 1]);
1154
+ else lines.push([region.start.line, region.end.column === 0 ? region.end.line : region.end.line + 1]);
1155
+ };
1156
+ for (const region of source?.regions ?? []) collect(region, false);
1157
+ return subtractIntervals(intersectIntervals(changed, hidden), visible);
1158
+ };
1159
+ return {
1160
+ base: side(all.base, diff.lhs, diff.rhs),
1161
+ head: side(all.head, diff.rhs, diff.lhs)
1162
+ };
1163
+ }
1164
+ //#endregion
1165
+ //#region src/review-api/image-decode.ts
1166
+ /** The one decoder for images entering the store, whether uploaded by a client
1167
+ * or read from a legacy review: bounded, single frame, re-encoded as PNG. */
1168
+ async function decodeImage(bytes) {
1169
+ const { default: sharp } = await import("sharp");
1170
+ try {
1171
+ const decoder = sharp(Buffer.from(bytes), {
1172
+ limitInputPixels: 2e7,
1173
+ failOn: "warning"
1174
+ });
1175
+ const metadata = await decoder.metadata();
1176
+ if (![
1177
+ "png",
1178
+ "jpeg",
1179
+ "webp"
1180
+ ].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1) throw new Error("Unsupported image");
1181
+ return await decoder.png().toBuffer();
1182
+ } catch {
1183
+ throw new ReviewInputError("Provide a valid single PNG, JPEG, or WebP image (at most 20 megapixels).");
1184
+ }
1185
+ }
1186
+ //#endregion
1187
+ //#region src/review-api/map-input.ts
1188
+ const label = z.string().trim().min(1);
1189
+ const nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "Must not be empty");
1190
+ const optionalNonEmptyStringSchema = nonEmptyStringSchema.optional();
1191
+ const softwareDataStoreForeignKeyRefSchema = z.union([nonEmptyStringSchema, z.strictObject({
1192
+ store: optionalNonEmptyStringSchema,
1193
+ table: nonEmptyStringSchema,
1194
+ field: nonEmptyStringSchema,
1195
+ label: optionalNonEmptyStringSchema,
1196
+ cardinality: z.enum(["one-to-one", "many-to-one"]).optional(),
1197
+ onDelete: optionalNonEmptyStringSchema,
1198
+ onUpdate: optionalNonEmptyStringSchema
1199
+ })]);
1200
+ const softwareDataStoreFieldSchema = z.lazy(() => z.record(nonEmptyStringSchema, z.union([z.strictObject({
1201
+ type: nonEmptyStringSchema,
1202
+ example: z.unknown().optional(),
1203
+ pk: z.boolean().optional(),
1204
+ fk: softwareDataStoreForeignKeyRefSchema.optional(),
1205
+ schema: softwareDataStoreFieldSchema.optional()
1206
+ }), softwareDataStoreFieldSchema])));
1207
+ const softwareDataStoreCollectionInputSchema = z.strictObject({
1208
+ label: optionalNonEmptyStringSchema,
1209
+ key: optionalNonEmptyStringSchema,
1210
+ schema: softwareDataStoreFieldSchema
1211
+ });
1212
+ const endpoint = label.describe("Existing element or data-store schema path. Resolution order: relative to the containing element, relative to its parent, then full path. \".\" means the containing element. At model root use full paths. Cross-level endpoints are allowed.");
1213
+ const range = z.strictObject({
1214
+ fromLine: z.number().int().positive(),
1215
+ toLine: z.number().int().positive()
1216
+ });
1217
+ const relationship = z.union([z.strictObject({
1218
+ kind: z.literal("call"),
1219
+ from: endpoint,
1220
+ to: endpoint,
1221
+ label: z.string().optional(),
1222
+ description: z.string().optional(),
1223
+ nthCallSite: z.number().int().nonnegative().optional()
1224
+ }), z.strictObject({
1225
+ kind: z.literal("semantic"),
1226
+ from: endpoint,
1227
+ to: endpoint,
1228
+ label: z.string().optional(),
1229
+ description: z.string().optional(),
1230
+ semanticKind: z.string().optional(),
1231
+ sourceRanges: z.array(range).optional()
1232
+ })]);
1233
+ const base = z.strictObject({
1234
+ id: label.optional().describe("Local ID, unique among siblings; defaults to the object key (or array index). Give array entries explicit IDs for stable paths. Nesting builds the full path: api under app becomes app.api. Do not repeat the parent path in the ID."),
1235
+ label: label.optional(),
1236
+ description: z.string().optional(),
1237
+ changeStatus: z.enum([
1238
+ "added",
1239
+ "removed",
1240
+ "modified",
1241
+ "unchanged"
1242
+ ]).optional(),
1243
+ relationships: z.array(relationship).optional().describe("Relationships scoped to this element. Endpoints may use relative names or full paths; they need not be direct children.")
1244
+ });
1245
+ const coverage = z.strictObject({
1246
+ files: z.array(z.union([label, z.strictObject({
1247
+ path: label,
1248
+ ranges: z.array(range).optional()
1249
+ })])).optional(),
1250
+ globs: z.array(label).optional()
1251
+ });
1252
+ const collection = (schema) => z.union([z.record(label, schema), z.array(schema)]);
1253
+ const code = base.extend({ sourceRanges: z.array(range.extend({ file: label })).optional() });
1254
+ const component = base.extend({
1255
+ coverage: coverage.optional(),
1256
+ codeElements: collection(code).optional()
1257
+ });
1258
+ const container = base.extend({
1259
+ coverage: coverage.optional(),
1260
+ components: collection(component).optional()
1261
+ });
1262
+ const store = container.extend({
1263
+ kind: z.enum([
1264
+ "database",
1265
+ "objectStore",
1266
+ "bucket",
1267
+ "artifactStore",
1268
+ "fileStore"
1269
+ ]).optional(),
1270
+ tables: z.record(label, softwareDataStoreCollectionInputSchema).optional(),
1271
+ documents: z.record(label, softwareDataStoreCollectionInputSchema).optional()
1272
+ });
1273
+ const system = base.extend({
1274
+ coverage: coverage.optional(),
1275
+ external: z.boolean().optional(),
1276
+ containers: collection(container).optional(),
1277
+ dataStores: collection(store).optional()
1278
+ });
1279
+ const mapInputSchema = z.strictObject({
1280
+ people: collection(base).optional(),
1281
+ systems: collection(system).optional(),
1282
+ relationships: z.array(relationship).optional().describe("Model-level relationships, including people and systems. Use full endpoint paths; external systems are systems with external: true.")
1283
+ });
1284
+ //#endregion
1285
+ //#region src/review-api/pull-request.ts
1286
+ const GH_TIMEOUT_MS = 2e4;
1287
+ const API_TIMEOUT_MS = 15e3;
1288
+ const FETCH_TIMEOUT_MS = 12e4;
1289
+ const LOCAL_TIMEOUT_MS = 3e4;
1290
+ const defaultPullRequestDeps = {
1291
+ run: (file, args, options) => new Promise((resolve, reject) => {
1292
+ execFile(file, args, {
1293
+ cwd: options.cwd,
1294
+ timeout: options.timeoutMs,
1295
+ maxBuffer: 16777216,
1296
+ env: {
1297
+ ...process.env,
1298
+ GIT_TERMINAL_PROMPT: "0",
1299
+ GH_PROMPT_DISABLED: "1",
1300
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes"
1301
+ }
1302
+ }, (error, stdout, stderr) => {
1303
+ if (!error) return resolve(stdout);
1304
+ const reason = error.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(stderr).trim() || error.message;
1305
+ reject(/* @__PURE__ */ new Error(`${file} ${args[0]}: ${reason}`));
1306
+ });
1307
+ }),
1308
+ fetch: (input, init) => fetch(input, init)
1309
+ };
1310
+ /** Host, owner/repo and number from a canonical PR URL (validated by the command schema). */
1311
+ function pullRequestAddress(url) {
1312
+ const { hostname, pathname } = new URL(url);
1313
+ const [, owner, repo, , number] = pathname.split("/");
1314
+ return {
1315
+ host: hostname,
1316
+ slug: `${owner}/${repo}`,
1317
+ number: Number(number)
1318
+ };
1319
+ }
1320
+ const metadataSchema = z.object({
1321
+ number: z.number().int().positive(),
1322
+ title: z.string(),
1323
+ baseRefName: z.string().min(1),
1324
+ baseRefOid: z.string().optional()
1325
+ });
1326
+ /** gh first (it carries the user's auth), then, for github.com only, GitHub's
1327
+ * public REST API: Enterprise hosts require gh. */
1328
+ async function readPullRequest(url, deps) {
1329
+ const { host, slug, number } = pullRequestAddress(url);
1330
+ let ghFailure;
1331
+ try {
1332
+ const stdout = await deps.run("gh", [
1333
+ "pr",
1334
+ "view",
1335
+ String(number),
1336
+ "--repo",
1337
+ `${host}/${slug}`,
1338
+ "--json",
1339
+ "number,title,baseRefName,baseRefOid"
1340
+ ], { timeoutMs: GH_TIMEOUT_MS });
1341
+ return {
1342
+ host,
1343
+ slug,
1344
+ ...metadataSchema.parse(JSON.parse(stdout))
1345
+ };
1346
+ } catch (error) {
1347
+ ghFailure = firstLine(errorMessage(error));
1348
+ }
1349
+ if (host !== "github.com") throw new ReviewInputError(`Could not read ${url}: gh failed (${ghFailure}). Run \`gh auth login --hostname ${host}\` with an account that can read the repository, then retry.`, 409);
1350
+ let response;
1351
+ try {
1352
+ response = await deps.fetch(`https://api.github.com/repos/${slug}/pulls/${number}`, {
1353
+ headers: {
1354
+ Accept: "application/vnd.github+json",
1355
+ "X-GitHub-Api-Version": "2022-11-28"
1356
+ },
1357
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
1358
+ });
1359
+ } catch (error) {
1360
+ throw unreadable(url, ghFailure, `the GitHub API is unreachable (${firstLine(errorMessage(error))})`);
1361
+ }
1362
+ if (response.status === 404) throw new ReviewInputError(`${url} was not found: it does not exist, or it is private and gh is not signed in to an account that can read it (run \`gh auth status\`). gh said: ${ghFailure}`, 404);
1363
+ if (response.status === 403 || response.status === 429) throw unreadable(url, ghFailure, response.headers.get("x-ratelimit-remaining") === "0" || response.status === 429 ? "the unauthenticated GitHub API rate limit is exhausted" : "the GitHub API refused the request");
1364
+ if (!response.ok) throw unreadable(url, ghFailure, `the GitHub API returned HTTP ${response.status}`);
1365
+ const parsed = z.object({
1366
+ number: z.number(),
1367
+ title: z.string(),
1368
+ base: z.object({
1369
+ ref: z.string().min(1),
1370
+ sha: z.string()
1371
+ })
1372
+ }).safeParse(await response.json().catch(() => void 0));
1373
+ if (!parsed.success) throw unreadable(url, ghFailure, "the GitHub API response was malformed");
1374
+ return {
1375
+ host,
1376
+ slug,
1377
+ number: parsed.data.number,
1378
+ title: parsed.data.title,
1379
+ baseRefName: parsed.data.base.ref,
1380
+ baseRefOid: parsed.data.base.sha
1381
+ };
1382
+ }
1383
+ /** Remotes by the host and owner/repo their configured URL names. The
1384
+ * configured URL, not the insteadOf rewrite: a mirror still names its repo. */
1385
+ async function githubRemotes(gitDir, deps) {
1386
+ return (await deps.run("git", [
1387
+ "--git-dir",
1388
+ gitDir,
1389
+ "config",
1390
+ "--get-regexp",
1391
+ String.raw`^remote\..*\.url$`
1392
+ ], { timeoutMs: LOCAL_TIMEOUT_MS }).catch(() => "")).split("\n").flatMap((line) => {
1393
+ const match = /^remote\.(.+)\.url\s+(\S+)/.exec(line.trim());
1394
+ const remote = match && parseGitRemote(match[2]);
1395
+ return match && remote ? [{
1396
+ name: match[1],
1397
+ host: remote.host,
1398
+ slug: remote.slug
1399
+ }] : [];
1400
+ });
1401
+ }
1402
+ /** Refs Review owns for one PR; the user's branches and bookmarks never move. */
1403
+ function pullRequestRefs(pr) {
1404
+ const prefix = `refs/review/github/${`${pr.host === "github.com" ? "" : `${pr.host}/`}${pr.slug}`.toLowerCase()}/pull/${pr.number}`;
1405
+ return {
1406
+ head: `${prefix}/head`,
1407
+ base: `${prefix}/base`,
1408
+ frozenBase: `${prefix}/frozen-base`
1409
+ };
1410
+ }
1411
+ /**
1412
+ * Fetch the PR head (refs/pull/N/head, so fork PRs work) and its base branch
1413
+ * into Review's own namespace, and return the comparison GitHub shows: the
1414
+ * head, and the merge base of the head with the base branch.
1415
+ *
1416
+ * A PR merged with a merge commit is absorbed: merge-base(base branch, head)
1417
+ * is the head itself and the diff is empty. GitHub freezes the base commit
1418
+ * (baseRefOid) at the PR's last update, so its merge base with the head is
1419
+ * the fork point GitHub diffs against, and it survives base branches that are
1420
+ * force-rebuilt or deleted.
1421
+ */
1422
+ async function fetchPullRequest(input, deps) {
1423
+ const { pullRequest: pr } = input;
1424
+ const refs = pullRequestRefs(pr);
1425
+ const git = (args, timeoutMs = LOCAL_TIMEOUT_MS) => deps.run("git", [
1426
+ "--git-dir",
1427
+ input.gitDir,
1428
+ ...args
1429
+ ], { timeoutMs }).then((stdout) => stdout.trim());
1430
+ const fetchRefs = (...refspecs) => git([
1431
+ "fetch",
1432
+ "--no-tags",
1433
+ "--no-write-fetch-head",
1434
+ "--quiet",
1435
+ input.remote,
1436
+ ...refspecs
1437
+ ], FETCH_TIMEOUT_MS);
1438
+ const commit = (ref) => git([
1439
+ "rev-parse",
1440
+ "--verify",
1441
+ "--quiet",
1442
+ `${ref}^{commit}`
1443
+ ]).catch(() => void 0);
1444
+ const mergeBase = (left, right) => git([
1445
+ "merge-base",
1446
+ left,
1447
+ right
1448
+ ]).catch(() => void 0);
1449
+ const frozenBase = async () => {
1450
+ if (!pr.baseRefOid) return void 0;
1451
+ if (!await commit(pr.baseRefOid)) await fetchRefs(`+${pr.baseRefOid}:${refs.frozenBase}`).catch(() => void 0);
1452
+ return commit(pr.baseRefOid);
1453
+ };
1454
+ let baseTip;
1455
+ try {
1456
+ await fetchRefs(`+refs/pull/${pr.number}/head:${refs.head}`, `+refs/heads/${pr.baseRefName}:${refs.base}`);
1457
+ baseTip = await commit(refs.base);
1458
+ } catch (error) {
1459
+ try {
1460
+ await fetchRefs(`+refs/pull/${pr.number}/head:${refs.head}`);
1461
+ } catch {
1462
+ throw new ReviewInputError(`Could not fetch PR #${pr.number} from remote "${input.remote}" (${pr.slug}). Check \`git fetch ${input.remote}\` works with your Git credentials, then retry. Git said: ${firstLine(errorMessage(error))}`, 409);
1463
+ }
1464
+ }
1465
+ const head = await commit(refs.head);
1466
+ baseTip ??= await frozenBase();
1467
+ if (!head || !baseTip) throw new ReviewInputError(`Fetched PR #${pr.number} but could not resolve its ${head ? `base branch ${pr.baseRefName}` : "head"}.`, 409);
1468
+ let base = await mergeBase(baseTip, head);
1469
+ if (!base || base === head) {
1470
+ const frozen = await frozenBase();
1471
+ if (frozen && frozen !== baseTip) base = await mergeBase(frozen, head) ?? base;
1472
+ }
1473
+ if (!base) throw new ReviewInputError(`PR #${pr.number}'s head shares no history with ${pr.baseRefName}.`, 409);
1474
+ if (input.kind === "jj") await indexForJj(input.rootPath, [base, head], git, deps);
1475
+ return {
1476
+ head,
1477
+ base
1478
+ };
1479
+ }
1480
+ /**
1481
+ * jj resolves only commits in its index, and `jj git import` indexes only
1482
+ * branches, remote branches and tags. Import the commits through a
1483
+ * transient tag, then drop it: the commits stay indexed (hidden, resolvable
1484
+ * by commit id) and no bookmark or tag remains.
1485
+ */
1486
+ async function indexForJj(rootPath, commits, git, deps) {
1487
+ const tags = commits.map(() => `refs/tags/review-index-${randomUUID()}`);
1488
+ const jjImport = () => deps.run("jj", [
1489
+ "-R",
1490
+ rootPath,
1491
+ "git",
1492
+ "import",
1493
+ "--ignore-working-copy",
1494
+ "--quiet"
1495
+ ], {
1496
+ cwd: rootPath,
1497
+ timeoutMs: LOCAL_TIMEOUT_MS
1498
+ });
1499
+ try {
1500
+ for (const [index, tag] of tags.entries()) await git([
1501
+ "update-ref",
1502
+ tag,
1503
+ commits[index]
1504
+ ]);
1505
+ await jjImport();
1506
+ } finally {
1507
+ for (const tag of tags) await git([
1508
+ "update-ref",
1509
+ "-d",
1510
+ tag
1511
+ ]).catch(() => void 0);
1512
+ await jjImport().catch(() => void 0);
1513
+ }
1514
+ }
1515
+ function unreadable(url, ghFailure, fallback) {
1516
+ return new ReviewInputError(`Could not read ${url}: gh failed (${ghFailure}) and ${fallback}. Run \`gh auth status\` and sign in with an account that can read the repository, then retry.`, 409);
1517
+ }
1518
+ function firstLine(text) {
1519
+ return text.split("\n")[0].slice(0, 300);
1520
+ }
1521
+ //#endregion
1522
+ //#region src/review-api/activity.ts
1523
+ const focusSchema = z.strictObject({
1524
+ description: z.string().trim().min(1).max(160),
1525
+ targetId: z.string().min(1).optional()
1526
+ });
1527
+ const activityBeginSchema = z.strictObject({ focus: focusSchema.nullable().optional() });
1528
+ const activityUpdateSchema = z.strictObject({
1529
+ activityId: z.string().min(1),
1530
+ focus: focusSchema.nullable().optional()
1531
+ });
1532
+ const activityEndSchema = z.strictObject({ activityId: z.string().min(1) });
1533
+ const activitySchema = z.discriminatedUnion("action", [
1534
+ activityBeginSchema.extend({ action: z.literal("begin") }),
1535
+ activityUpdateSchema.extend({ action: z.literal("update") }),
1536
+ activityEndSchema.extend({ action: z.literal("end") })
1537
+ ]);
1538
+ const ACTIVITY_TTL_MS = 18e4;
1539
+ var ReviewActivity = class {
1540
+ db;
1541
+ assertReview;
1542
+ timers = /* @__PURE__ */ new Map();
1543
+ listeners = /* @__PURE__ */ new Set();
1544
+ working = /* @__PURE__ */ new Set();
1545
+ workingListeners = /* @__PURE__ */ new Set();
1546
+ constructor(db, assertReview) {
1547
+ this.db = db;
1548
+ this.assertReview = assertReview;
1549
+ db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
1550
+ activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
1551
+ started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
1552
+ DROP TABLE IF EXISTS authoring_sessions;`);
1553
+ for (const row of db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) {
1554
+ this.working.add(String(row.review_id));
1555
+ this.scheduleExpiry(String(row.review_id));
1556
+ }
1557
+ }
1558
+ subscribe(listener) {
1559
+ this.listeners.add(listener);
1560
+ return () => {
1561
+ this.listeners.delete(listener);
1562
+ };
1563
+ }
1564
+ /** Fires only when a review starts or stops being authored, never on
1565
+ * renewals or focus changes. */
1566
+ subscribeWorking(listener) {
1567
+ this.workingListeners.add(listener);
1568
+ return () => {
1569
+ this.workingListeners.delete(listener);
1570
+ };
1571
+ }
1572
+ isWorking(reviewId) {
1573
+ return this.working.has(reviewId);
1574
+ }
1575
+ changed(reviewId) {
1576
+ const working = this.db.prepare("SELECT 1 FROM authoring_presences WHERE review_id=? AND expires_at>? LIMIT 1").get(reviewId, Date.now()) !== void 0;
1577
+ if (working !== this.working.has(reviewId)) {
1578
+ if (working) this.working.add(reviewId);
1579
+ else this.working.delete(reviewId);
1580
+ for (const notify of this.workingListeners) notify();
1581
+ }
1582
+ for (const notify of this.listeners) notify(reviewId);
1583
+ }
1584
+ live(reviewId) {
1585
+ return this.db.prepare("SELECT activity_id,slot,expires_at,focus,surface FROM authoring_presences WHERE review_id=? AND expires_at>? ORDER BY rowid").all(reviewId, Date.now());
1586
+ }
1587
+ read(reviewId) {
1588
+ const live = this.live(reviewId);
1589
+ const snapshot = {
1590
+ workingCount: live.length,
1591
+ expiresAt: live.length ? Math.max(...live.map((row) => Number(row.expires_at))) : null
1592
+ };
1593
+ if (live.length) snapshot.activities = live.map((row) => {
1594
+ const presence = {
1595
+ activityId: String(row.activity_id),
1596
+ slot: Number(row.slot)
1597
+ };
1598
+ if (row.focus) presence.focus = focusSchema.parse(JSON.parse(String(row.focus)));
1599
+ if (row.surface === "document" || row.surface === "lenses") presence.surface = row.surface;
1600
+ return presence;
1601
+ });
1602
+ return snapshot;
1603
+ }
1604
+ /** Inside the caller's write transaction: the presence a write belongs to,
1605
+ * renewed and moved to the write's surface. The named one if it is live;
1606
+ * otherwise, with none named, the review's only presence. A write is never
1607
+ * refused for this. Call `extended` once the transaction commits. */
1608
+ attribute(reviewId, surface, activityId) {
1609
+ const live = this.live(reviewId);
1610
+ const owner = activityId === void 0 ? live.length === 1 ? String(live[0].activity_id) : void 0 : live.some((row) => row.activity_id === activityId) ? activityId : void 0;
1611
+ if (owner === void 0) return void 0;
1612
+ this.db.prepare("UPDATE authoring_presences SET expires_at=?,surface=? WHERE activity_id=?").run(Date.now() + ACTIVITY_TTL_MS, surface, owner);
1613
+ return owner;
1614
+ }
1615
+ /** Move the expiry timer after a committed `attribute`. */
1616
+ extended(reviewId) {
1617
+ this.scheduleExpiry(reviewId);
1618
+ this.changed(reviewId);
1619
+ }
1620
+ update(reviewId, value) {
1621
+ const input = activitySchema.parse(value);
1622
+ const now = Date.now();
1623
+ let activityId;
1624
+ this.db.exec("BEGIN IMMEDIATE");
1625
+ try {
1626
+ this.assertReview?.(reviewId);
1627
+ if (input.action === "begin") {
1628
+ activityId = randomUUID();
1629
+ const taken = new Set(this.live(reviewId).map((row) => Number(row.slot)));
1630
+ let slot = 0;
1631
+ while (taken.has(slot)) slot += 1;
1632
+ this.db.prepare("INSERT INTO authoring_presences(activity_id,review_id,slot,started_at,expires_at,focus) VALUES(?,?,?,?,?,?)").run(activityId, reviewId, slot, now, now + ACTIVITY_TTL_MS, input.focus ? JSON.stringify(input.focus) : null);
1633
+ } else {
1634
+ activityId = input.activityId;
1635
+ if (input.action === "end") this.db.prepare("DELETE FROM authoring_presences WHERE activity_id=? AND review_id=?").run(activityId, reviewId);
1636
+ else if (this.db.prepare(`UPDATE authoring_presences SET expires_at=?${input.focus === void 0 ? "" : ",focus=?"}
1637
+ WHERE activity_id=? AND review_id=? AND expires_at>?`).run(...[
1638
+ now + 18e4,
1639
+ ...input.focus === void 0 ? [] : [input.focus === null ? null : JSON.stringify(input.focus)],
1640
+ activityId,
1641
+ reviewId,
1642
+ now
1643
+ ]).changes === 0) throw new ReviewInputError("This activity ended or expired. Begin a new one.", 409);
1644
+ }
1645
+ this.db.exec("COMMIT");
1646
+ } catch (error) {
1647
+ this.db.exec("ROLLBACK");
1648
+ throw error;
1649
+ }
1650
+ this.scheduleExpiry(reviewId);
1651
+ this.changed(reviewId);
1652
+ const result = this.read(reviewId);
1653
+ if (input.action === "begin") result.activityId = activityId;
1654
+ return result;
1655
+ }
1656
+ /** One timer per review, for its soonest-expiring presence. */
1657
+ scheduleExpiry(reviewId) {
1658
+ clearTimeout(this.timers.get(reviewId));
1659
+ this.timers.delete(reviewId);
1660
+ const next = this.db.prepare("SELECT MIN(expires_at) AS expires_at FROM authoring_presences WHERE review_id=? AND expires_at>?").get(reviewId, Date.now());
1661
+ if (next?.expires_at === null || next?.expires_at === void 0) return;
1662
+ const timer = setTimeout(() => {
1663
+ this.scheduleExpiry(reviewId);
1664
+ this.changed(reviewId);
1665
+ }, Math.max(1, Number(next.expires_at) - Date.now()));
1666
+ timer.unref?.();
1667
+ this.timers.set(reviewId, timer);
1668
+ }
1669
+ /** Called when another database connection commits session changes. */
1670
+ refresh() {
1671
+ const ids = new Set(this.timers.keys());
1672
+ for (const row of this.db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) ids.add(String(row.review_id));
1673
+ for (const id of ids) {
1674
+ this.scheduleExpiry(id);
1675
+ this.changed(id);
1676
+ }
1677
+ }
1678
+ /** The store deletes the session atomically with its review before notifying. */
1679
+ deleted(reviewId) {
1680
+ clearTimeout(this.timers.get(reviewId));
1681
+ this.timers.delete(reviewId);
1682
+ this.changed(reviewId);
1683
+ }
1684
+ close() {
1685
+ this.listeners.clear();
1686
+ this.workingListeners.clear();
1687
+ for (const timer of this.timers.values()) clearTimeout(timer);
1688
+ this.timers.clear();
1689
+ }
1690
+ };
1691
+ /** A semantic selection, independent of comment/thread creation. */
1692
+ const AgentSelectionSchema = z.strictObject({
1693
+ target: z.discriminatedUnion("kind", [z.strictObject({
1694
+ kind: z.literal("text"),
1695
+ quote: z.string(),
1696
+ /** Where the quote is: characters `start` to `end` of the text of the
1697
+ * review block `blockId`, which was `text` then. The block keeps its
1698
+ * id across versions; comparing its text then and now finds the quote
1699
+ * again, or tells that it changed. */
1700
+ anchor: z.strictObject({
1701
+ blockId: z.string().min(1).max(200),
1702
+ start: z.number().int().nonnegative(),
1703
+ end: z.number().int().positive(),
1704
+ text: z.string().max(2e4)
1705
+ }).refine((anchor) => anchor.start < anchor.end, "an empty anchor").optional()
1706
+ }), z.strictObject({
1707
+ kind: z.literal("code"),
1708
+ path: z.string(),
1709
+ side: z.enum(["base", "head"]),
1710
+ startLine: z.number().int().positive(),
1711
+ endLine: z.number().int().positive()
1712
+ })]),
1713
+ title: z.string().max(500),
1714
+ detail: z.string().max(24e3).optional(),
1715
+ revision: z.string().max(200).optional(),
1716
+ selectedDiff: ReviewSelectedDiffSchema.optional(),
1717
+ apiSource: ReviewApiSelectionSourceSchema.optional()
1718
+ });
1719
+ function selectionMarkdown(selection, sourceExcerpt = "", diffPaths) {
1720
+ const target = selection.target;
1721
+ if (selection.selectedDiff) return selectedDiffMarkdown(selection.selectedDiff, diffPaths);
1722
+ if (target.kind === "text") return target.quote.split(/\r?\n/).map((line) => `> ${line}`).join("\n");
1723
+ return [
1724
+ `## ${selection.title}`,
1725
+ selection.detail ?? "",
1726
+ sourceExcerpt
1727
+ ].filter(Boolean).join("\n\n");
1728
+ }
1729
+ function selectedDiffMarkdown(diff, paths) {
1730
+ const oldCount = diff.rows.filter((row) => row.kind !== "added").length;
1731
+ const newCount = diff.rows.filter((row) => row.kind !== "deleted").length;
1732
+ const lines = diff.rows.map((row) => `${row.kind === "added" ? "+" : row.kind === "deleted" ? "-" : " "}${row.text}`);
1733
+ const fence = "`".repeat(Math.max(3, ...lines.flatMap((line) => [...line.matchAll(/`+/g)].map((match) => match[0].length + 1))));
1734
+ return [
1735
+ `Base: ${paths?.base ?? "a/"}`,
1736
+ `Head: ${paths?.head ?? "b/"}`,
1737
+ `Range: -${diff.oldStart},${oldCount} +${diff.newStart},${newCount}`,
1738
+ "",
1739
+ `${fence}diff`,
1740
+ ...lines,
1741
+ fence
1742
+ ].join("\n");
1743
+ }
1744
+ //#endregion
1745
+ //#region src/ask/thread-state.ts
1746
+ const askAgentIds = [
1747
+ "claude",
1748
+ "codex",
1749
+ "cursor",
1750
+ "opencode",
1751
+ "pi"
1752
+ ];
1753
+ const permissionOptionSchema = z.object({
1754
+ optionId: z.string(),
1755
+ name: z.string(),
1756
+ kind: z.enum([
1757
+ "allow_once",
1758
+ "allow_always",
1759
+ "reject_once",
1760
+ "reject_always"
1761
+ ])
1762
+ });
1763
+ /** What a question carries besides its text, as the thread shows it. An
1764
+ * image shows by name: its bytes go to the agent, not into the thread. */
1765
+ const askAttachmentSchema = z.discriminatedUnion("kind", [z.object({
1766
+ kind: z.literal("file"),
1767
+ path: z.string()
1768
+ }), z.object({
1769
+ kind: z.literal("image"),
1770
+ name: z.string()
1771
+ })]);
1772
+ const askEntrySchema = z.discriminatedUnion("kind", [
1773
+ z.object({
1774
+ kind: z.literal("user"),
1775
+ id: z.string(),
1776
+ text: z.string(),
1777
+ /** When it was asked, in epoch milliseconds; unknown for a replayed one. */
1778
+ at: z.number().optional(),
1779
+ attachments: z.array(askAttachmentSchema).optional()
1780
+ }),
1781
+ z.object({
1782
+ kind: z.literal("agent"),
1783
+ id: z.string(),
1784
+ text: z.string()
1785
+ }),
1786
+ z.object({
1787
+ kind: z.literal("tool"),
1788
+ id: z.string(),
1789
+ title: z.string(),
1790
+ toolKind: z.string(),
1791
+ status: z.enum([
1792
+ "pending",
1793
+ "in_progress",
1794
+ "completed",
1795
+ "failed"
1796
+ ]),
1797
+ /** The command it ran, or the file it opened. */
1798
+ input: z.string().optional(),
1799
+ /** The agent's own note on why, when it gives one. */
1800
+ summary: z.string().optional(),
1801
+ /** What came back, shortened. */
1802
+ output: z.string().optional()
1803
+ }),
1804
+ z.object({
1805
+ kind: z.literal("permission"),
1806
+ id: z.string(),
1807
+ title: z.string(),
1808
+ toolKind: z.string(),
1809
+ /** The command it would run, or the file it would open, when the title
1810
+ * does not say. */
1811
+ input: z.string().optional(),
1812
+ options: z.array(permissionOptionSchema),
1813
+ /** The chosen option, `cancelled`, or absent while the user decides. */
1814
+ outcome: z.string().optional(),
1815
+ /** Refused by Whiteboard's read-only policy, not by the user. */
1816
+ automatic: z.boolean().optional()
1817
+ }),
1818
+ z.object({
1819
+ kind: z.literal("notice"),
1820
+ id: z.string(),
1821
+ severity: z.string(),
1822
+ title: z.string(),
1823
+ description: z.string().optional()
1824
+ })
1825
+ ]);
1826
+ /** One of the agent's settings: its choices, and the one in use. */
1827
+ const askSelectSchema = z.object({
1828
+ current: z.string(),
1829
+ options: z.array(z.object({
1830
+ value: z.string(),
1831
+ name: z.string(),
1832
+ description: z.string().optional()
1833
+ }))
1834
+ });
1835
+ /** The settings a reviewer can pick: the model, and how hard it thinks. */
1836
+ const askChoiceKinds = ["model", "effort"];
1837
+ /** What the agent offers of each, when it offers it. */
1838
+ const askChoicesSchema = z.object({
1839
+ model: askSelectSchema.optional(),
1840
+ effort: askSelectSchema.optional()
1841
+ });
1842
+ /** The values picked, before the agent says what it offers. */
1843
+ const askPicksSchema = z.strictObject({
1844
+ model: z.string().min(1).max(200).optional(),
1845
+ effort: z.string().min(1).max(200).optional()
1846
+ });
1847
+ /** A slash command the agent takes, its skills among them. */
1848
+ const askCommandSchema = z.object({
1849
+ name: z.string(),
1850
+ description: z.string(),
1851
+ /** What it takes after its name, when it takes anything. */
1852
+ hint: z.string().optional()
1853
+ });
1854
+ /** What a question to the agent may carry besides text and file links. */
1855
+ const askAcceptsSchema = z.object({ image: z.boolean() });
1856
+ /** What an agent offers before anything is asked of it, as it last said. */
1857
+ const askOfferSchema = z.object({
1858
+ choices: askChoicesSchema,
1859
+ commands: z.array(askCommandSchema).optional(),
1860
+ accepts: askAcceptsSchema.optional()
1861
+ });
1862
+ /** A question as the reviewer asks it: its text, the checkout files it
1863
+ * mentions, and any images. */
1864
+ const askQuestionSchema = z.strictObject({
1865
+ text: z.string().trim().min(1).max(8e3),
1866
+ /** Paths relative to the checkout's root. */
1867
+ mentions: z.array(z.string().min(1).max(400)).max(20).optional(),
1868
+ images: z.array(z.strictObject({
1869
+ name: z.string().min(1).max(200),
1870
+ mimeType: z.enum([
1871
+ "image/png",
1872
+ "image/jpeg",
1873
+ "image/gif",
1874
+ "image/webp"
1875
+ ]),
1876
+ data: z.string().min(1).max(7e6)
1877
+ })).max(4).optional()
1878
+ });
1879
+ /** How full the agent's context window is, and what the session cost. */
1880
+ const askUsageSchema = z.object({
1881
+ used: z.number(),
1882
+ size: z.number(),
1883
+ cost: z.object({
1884
+ amount: z.number(),
1885
+ currency: z.string()
1886
+ }).optional()
1887
+ });
1888
+ const statusSchema = z.enum([
1889
+ "starting",
1890
+ "running",
1891
+ "waiting",
1892
+ "idle",
1893
+ "failed"
1894
+ ]);
1895
+ /** Everything the Ask panel renders. A watcher gets it whole once, then
1896
+ * follows it through `AskChange`s. */
1897
+ const askThreadStateSchema = z.object({
1898
+ id: z.string(),
1899
+ agent: z.enum(askAgentIds),
1900
+ agentName: z.string(),
1901
+ status: statusSchema,
1902
+ error: z.string().optional(),
1903
+ /** The agent's login lapsed: the command that signs in again. */
1904
+ signIn: z.string().optional(),
1905
+ /** The mode that keeps the agent from changing files was accepted. */
1906
+ readOnly: z.boolean(),
1907
+ /** The agent edits and runs commands without asking. */
1908
+ bypass: z.boolean(),
1909
+ head: z.string(),
1910
+ /** The checkout the agent works in. */
1911
+ cwd: z.string(),
1912
+ /** Absent until the agent says, or when it offers no choice. */
1913
+ choices: askChoicesSchema.optional(),
1914
+ /** Absent until the agent says, or when it takes none. */
1915
+ commands: z.array(askCommandSchema).optional(),
1916
+ accepts: askAcceptsSchema.optional(),
1917
+ usage: askUsageSchema.optional(),
1918
+ /** What the agent calls the conversation. */
1919
+ title: z.string().optional(),
1920
+ selection: z.object({
1921
+ title: z.string(),
1922
+ quote: z.string().optional()
1923
+ }),
1924
+ entries: z.array(askEntrySchema)
1925
+ });
1926
+ /** One change to a thread. Answer text arrives as `append`, so a streamed
1927
+ * token costs its own length, not the thread's. */
1928
+ const askChangeSchema = z.discriminatedUnion("type", [
1929
+ z.object({
1930
+ type: z.literal("set"),
1931
+ status: statusSchema.optional(),
1932
+ readOnly: z.boolean().optional(),
1933
+ bypass: z.boolean().optional(),
1934
+ choices: askChoicesSchema.optional(),
1935
+ commands: z.array(askCommandSchema).optional(),
1936
+ accepts: askAcceptsSchema.optional(),
1937
+ usage: askUsageSchema.optional(),
1938
+ title: z.string().optional(),
1939
+ /** `null` clears the error; absent leaves it. */
1940
+ error: z.string().nullable().optional(),
1941
+ /** `null` clears it; absent leaves it. */
1942
+ signIn: z.string().nullable().optional()
1943
+ }),
1944
+ z.object({
1945
+ type: z.literal("add"),
1946
+ entry: askEntrySchema
1947
+ }),
1948
+ z.object({
1949
+ type: z.literal("entry"),
1950
+ entry: askEntrySchema
1951
+ }),
1952
+ z.object({
1953
+ type: z.literal("append"),
1954
+ id: z.string(),
1955
+ text: z.string()
1956
+ }),
1957
+ z.object({
1958
+ type: z.literal("remove"),
1959
+ ids: z.array(z.string())
1960
+ })
1961
+ ]);
1962
+ z.union([z.object({
1963
+ seq: z.number().int().nonnegative(),
1964
+ snapshot: askThreadStateSchema
1965
+ }), z.object({
1966
+ seq: z.number().int().positive(),
1967
+ change: askChangeSchema
1968
+ })]);
1969
+ /** The server and the panel both advance a thread with this, so they agree. */
1970
+ function applyAskChange(state, change) {
1971
+ switch (change.type) {
1972
+ case "set": {
1973
+ const { error: _error, signIn: _signIn, ...rest } = state;
1974
+ const next = {
1975
+ ...rest,
1976
+ status: change.status ?? state.status,
1977
+ readOnly: change.readOnly ?? state.readOnly,
1978
+ bypass: change.bypass ?? state.bypass
1979
+ };
1980
+ const choices = change.choices ?? state.choices;
1981
+ if (choices) next.choices = choices;
1982
+ const commands = change.commands ?? state.commands;
1983
+ if (commands) next.commands = commands;
1984
+ const accepts = change.accepts ?? state.accepts;
1985
+ if (accepts) next.accepts = accepts;
1986
+ const usage = change.usage ?? state.usage;
1987
+ if (usage) next.usage = usage;
1988
+ const title = change.title ?? state.title;
1989
+ if (title) next.title = title;
1990
+ const error = change.error === void 0 ? state.error : change.error;
1991
+ if (error) next.error = error;
1992
+ const signIn = change.signIn === void 0 ? state.signIn : change.signIn;
1993
+ if (signIn) next.signIn = signIn;
1994
+ return next;
1995
+ }
1996
+ case "add": return {
1997
+ ...state,
1998
+ entries: [...state.entries, change.entry]
1999
+ };
2000
+ case "entry": return {
2001
+ ...state,
2002
+ entries: state.entries.map((entry) => entry.id === change.entry.id && entry.kind === change.entry.kind ? change.entry : entry)
2003
+ };
2004
+ case "remove": {
2005
+ const ids = new Set(change.ids);
2006
+ return {
2007
+ ...state,
2008
+ entries: state.entries.filter((entry) => !ids.has(entry.id))
2009
+ };
2010
+ }
2011
+ case "append": return {
2012
+ ...state,
2013
+ entries: state.entries.map((entry) => entry.kind === "agent" && entry.id === change.id ? {
2014
+ ...entry,
2015
+ text: entry.text + change.text
2016
+ } : entry)
2017
+ };
2018
+ }
2019
+ }
2020
+ //#endregion
2021
+ //#region src/review-api/ask-history.ts
2022
+ /** A saved Ask conversation: its history entry, and how to reopen it. */
2023
+ const askRecordSchema = z.object({
2024
+ id: z.string(),
2025
+ agent: z.enum(askAgentIds),
2026
+ /** What the agent calls it, else the first question. */
2027
+ title: z.string(),
2028
+ /** The first saved user question, independent of the agent's title. */
2029
+ question: z.string().optional(),
2030
+ selection: AgentSelectionSchema,
2031
+ /** The commit the agent read. */
2032
+ head: z.string(),
2033
+ createdAt: z.string(),
2034
+ updatedAt: z.string()
2035
+ }).extend({
2036
+ reviewId: z.string(),
2037
+ /** The ACP session the agent keeps the transcript under. */
2038
+ sessionId: z.string(),
2039
+ /** The review version asked about; its pins recreate the checkout. */
2040
+ version: z.number().int(),
2041
+ cwd: z.string(),
2042
+ /** What the panel showed last; absent until a turn ends or it closes. */
2043
+ entries: z.array(askEntrySchema).optional(),
2044
+ /** The agent edits and runs commands without asking. */
2045
+ bypass: z.boolean().optional()
2046
+ });
2047
+ const rowSchema = z.object({
2048
+ id: z.string(),
2049
+ review_id: z.string(),
2050
+ agent: z.string(),
2051
+ session_id: z.string(),
2052
+ version: z.number(),
2053
+ head: z.string(),
2054
+ cwd: z.string(),
2055
+ selection: z.string(),
2056
+ title: z.string(),
2057
+ created_at: z.string(),
2058
+ updated_at: z.string(),
2059
+ entries: z.string().nullable(),
2060
+ bypass: z.number()
2061
+ }).transform((row) => askRecordSchema.parse({
2062
+ id: row.id,
2063
+ reviewId: row.review_id,
2064
+ agent: row.agent,
2065
+ sessionId: row.session_id,
2066
+ version: row.version,
2067
+ head: row.head,
2068
+ cwd: row.cwd,
2069
+ selection: JSON.parse(row.selection),
2070
+ title: row.title,
2071
+ createdAt: row.created_at,
2072
+ updatedAt: row.updated_at,
2073
+ entries: row.entries === null ? void 0 : JSON.parse(row.entries),
2074
+ bypass: row.bypass === 1
2075
+ }));
2076
+ /** Saved Ask conversations, per review. Shared reviews are not in
2077
+ * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
2078
+ var AskHistory = class {
2079
+ db;
2080
+ constructor(db) {
2081
+ this.db = db;
2082
+ db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
2083
+ id TEXT PRIMARY KEY,
2084
+ review_id TEXT NOT NULL,
2085
+ agent TEXT NOT NULL,
2086
+ session_id TEXT NOT NULL,
2087
+ version INTEGER NOT NULL,
2088
+ head TEXT NOT NULL,
2089
+ cwd TEXT NOT NULL,
2090
+ selection TEXT NOT NULL,
2091
+ title TEXT NOT NULL,
2092
+ created_at TEXT NOT NULL,
2093
+ updated_at TEXT NOT NULL,
2094
+ entries TEXT,
2095
+ bypass INTEGER NOT NULL DEFAULT 0,
2096
+ UNIQUE(agent, session_id));
2097
+ CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
2098
+ if (!db.prepare("PRAGMA table_info(ask_conversations)").all().some((column) => String(column.name) === "bypass")) db.exec("ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0");
2099
+ db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
2100
+ db.exec("CREATE TABLE IF NOT EXISTS ask_agent_model_offers(agent TEXT NOT NULL, model TEXT NOT NULL, offer TEXT NOT NULL, PRIMARY KEY(agent, model))");
2101
+ }
2102
+ save(record) {
2103
+ this.db.prepare(`INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass)
2104
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(record.id, record.reviewId, record.agent, record.sessionId, record.version, record.head, record.cwd, JSON.stringify(record.selection), record.title, record.createdAt, record.updatedAt, record.bypass ? 1 : 0);
2105
+ }
2106
+ /** Whether reopening the conversation bypasses permissions. */
2107
+ setBypass(id, bypass) {
2108
+ this.db.prepare("UPDATE ask_conversations SET bypass=? WHERE id=?").run(bypass ? 1 : 0, id);
2109
+ }
2110
+ /** Points a conversation at a new session, when its agent could not
2111
+ * reopen the one it had. */
2112
+ updateSession(id, sessionId) {
2113
+ this.db.prepare("UPDATE ask_conversations SET session_id=? WHERE id=?").run(sessionId, id);
2114
+ }
2115
+ /** Keeps what the panel shows, so a reopen need not wait on the agent. */
2116
+ saveEntries(id, entries) {
2117
+ this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
2118
+ }
2119
+ /** The agent's name for the conversation, which the list shows. */
2120
+ rename(id, title) {
2121
+ this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
2122
+ }
2123
+ saveOffer(agent, offer) {
2124
+ this.db.prepare("INSERT OR REPLACE INTO ask_agent_offers(agent, offer) VALUES(?, ?)").run(agent, JSON.stringify(offer));
2125
+ this.saveModelOffer(agent, offer);
2126
+ }
2127
+ /** Keeps what the agent offers with the offer's model, without making
2128
+ * it what the agent offered last. */
2129
+ saveModelOffer(agent, offer) {
2130
+ const model = offer.choices.model?.current;
2131
+ if (!model) return;
2132
+ this.db.prepare("INSERT OR REPLACE INTO ask_agent_model_offers(agent, model, offer) VALUES(?, ?, ?)").run(agent, model, JSON.stringify(offer));
2133
+ }
2134
+ /** What the agent offered last, or last with the model given. */
2135
+ offer(agent, model) {
2136
+ const row = z.object({ offer: z.string() }).safeParse(model === void 0 ? this.db.prepare("SELECT offer FROM ask_agent_offers WHERE agent=?").get(agent) : this.db.prepare("SELECT offer FROM ask_agent_model_offers WHERE agent=? AND model=?").get(agent, model)).data;
2137
+ return row && askOfferSchema.safeParse(JSON.parse(row.offer)).data;
2138
+ }
2139
+ touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
2140
+ this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
2141
+ }
2142
+ /** Newest first. */
2143
+ list(reviewId) {
2144
+ return this.db.prepare("SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC").all(reviewId).map((row) => {
2145
+ const { reviewId: _reviewId, sessionId: _sessionId, version: _version, cwd: _cwd, entries, bypass: _bypass, ...entry } = rowSchema.parse(row);
2146
+ const question = entries?.find((entry) => entry.kind === "user")?.text;
2147
+ if (question?.trim()) entry.question = question;
2148
+ return entry;
2149
+ });
2150
+ }
2151
+ get(id) {
2152
+ const row = this.db.prepare("SELECT * FROM ask_conversations WHERE id=?").get(id);
2153
+ return row ? rowSchema.parse(row) : void 0;
2154
+ }
2155
+ delete(id) {
2156
+ this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
2157
+ }
2158
+ };
2159
+ //#endregion
2160
+ //#region src/review-api/origin.ts
2161
+ const pullRequestUrl = z.string().regex(/^https:\/\/[a-z0-9.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9]\d*$/, "Use a canonical GitHub PR URL: https://github.com/owner/repository/pull/123, or the same path on a GitHub Enterprise host.").refine((url) => Number.isSafeInteger(Number(url.split("/").at(-1))), "PR number is too large.");
2162
+ /** One key per PR: GitHub owner and repository names are case-insensitive,
2163
+ * and the URL pattern admits only ASCII, so lowercasing is canonical. */
2164
+ function pullRequestKey(url) {
2165
+ return url.toLowerCase();
2166
+ }
2167
+ /** Omission preserves identity; null detaches it without changing import metadata. */
2168
+ function setPullRequest(snapshot, url) {
2169
+ if (url === void 0) return;
2170
+ if (url === null) {
2171
+ if (snapshot.origin) {
2172
+ delete snapshot.origin.pullRequestUrl;
2173
+ delete snapshot.origin.pullRequestNumber;
2174
+ }
2175
+ return;
2176
+ }
2177
+ snapshot.origin = {
2178
+ ...snapshot.origin,
2179
+ pullRequestUrl: url,
2180
+ pullRequestNumber: Number(url.split("/").at(-1))
2181
+ };
2182
+ }
2183
+ //#endregion
2184
+ //#region src/review-api/store-schema.ts
2185
+ function initializeReviewStoreSchema(db) {
2186
+ db.exec(`
2187
+ PRAGMA journal_mode=WAL;
2188
+ PRAGMA foreign_keys=ON;
2189
+ CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL);
2190
+ CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
2191
+ PRIMARY KEY(review_id,version));
2192
+ DROP TABLE IF EXISTS receipts;
2193
+ CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, dismissed_at TEXT);
2194
+ CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
2195
+ CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
2196
+ kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
2197
+ CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
2198
+ PRIMARY KEY(review_id,file));
2199
+ DROP TABLE IF EXISTS review_viewed;
2200
+ CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
2201
+ CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
2202
+ DROP TABLE IF EXISTS authoring_drafts;
2203
+ `);
2204
+ }
2205
+ //#endregion
2206
+ //#region src/review-api/store.ts
2207
+ const reviewId = z.string().min(1);
2208
+ /** There is one scratchpad. Its id is fixed so a skill can name it. */
2209
+ const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
2210
+ const DIAGRAM_TYPES = /* @__PURE__ */ new Set([
2211
+ "sequence",
2212
+ "flow_diagram",
2213
+ "call_stack_diff",
2214
+ "database_lens",
2215
+ "software_map"
2216
+ ]);
2217
+ const SCRATCHPAD_TITLE = "Scratchpad";
2218
+ const activityId = z.string().min(1).optional().describe("The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.");
2219
+ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
2220
+ z.strictObject({
2221
+ type: z.literal("delete"),
2222
+ reviewId
2223
+ }),
2224
+ z.strictObject({
2225
+ type: z.literal("attention"),
2226
+ reviewId,
2227
+ action: z.enum([
2228
+ "view",
2229
+ "dismiss",
2230
+ "restore"
2231
+ ])
2232
+ }),
2233
+ z.strictObject({
2234
+ type: z.literal("create"),
2235
+ /** Required unless pullRequestUrl alone names the source; then the PR title. */
2236
+ title: z.string().trim().min(1).optional(),
2237
+ target: reviewTargetSchema.optional(),
2238
+ pullRequestUrl: pullRequestUrl.optional(),
2239
+ /** With pullRequestUrl and no target: the checkout to fetch the PR into. */
2240
+ repositoryId: z.string().min(1).optional().describe("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."),
2241
+ /** Return the existing review for pullRequestUrl instead of creating one. */
2242
+ reuseExisting: z.boolean().optional().describe("Default true: return the existing review for pullRequestUrl. false creates a separate review."),
2243
+ /** The one scratchpad: no target, no pins; every reference names its own. */
2244
+ kind: z.literal("scratchpad").optional()
2245
+ }),
2246
+ z.strictObject({
2247
+ type: z.literal("set_target"),
2248
+ reviewId,
2249
+ target: reviewTargetSchema,
2250
+ pullRequestUrl: pullRequestUrl.nullable().optional()
2251
+ }),
2252
+ z.strictObject({
2253
+ type: z.literal("edit"),
2254
+ reviewId,
2255
+ edit: editSchema,
2256
+ activityId
2257
+ }),
2258
+ z.strictObject({
2259
+ type: z.literal("lens_edit"),
2260
+ reviewId,
2261
+ edit: lensEditSchema,
2262
+ activityId
2263
+ }),
2264
+ z.strictObject({
2265
+ type: z.literal("rename"),
2266
+ reviewId,
2267
+ title: z.string().trim().min(1)
2268
+ }),
2269
+ z.strictObject({
2270
+ type: z.literal("restore"),
2271
+ reviewId,
2272
+ version: z.number().int().nonnegative()
2273
+ })
2274
+ ]) });
2275
+ /** One instance owned by the desktop server. All writers go through execute().
2276
+ * The queue includes async validation; SQLite transactions contain only writes.
2277
+ * This prototype uses a new, explicitly supplied database, never an existing profile.
2278
+ */
2279
+ var ReviewStore = class {
2280
+ providers;
2281
+ activity;
2282
+ askHistory;
2283
+ db;
2284
+ pending = Promise.resolve();
2285
+ closing = false;
2286
+ liveSources = /* @__PURE__ */ new Map();
2287
+ refreshTimer;
2288
+ refreshSubscribers = 0;
2289
+ watchWorktrees() {
2290
+ this.refreshSubscribers++;
2291
+ this.refreshTimer ??= setInterval(() => {
2292
+ this.refreshWorktrees();
2293
+ }, 1e3);
2294
+ this.refreshTimer.unref();
2295
+ return () => {
2296
+ if (--this.refreshSubscribers === 0) {
2297
+ clearInterval(this.refreshTimer);
2298
+ this.refreshTimer = void 0;
2299
+ }
2300
+ };
2301
+ }
2302
+ async projectLiveSource(snapshot, current = snapshot) {
2303
+ if (snapshot.target?.kind !== "worktree") {
2304
+ await this.providers.sourcePins?.(snapshot);
2305
+ const unavailable = await this.providers.unavailableAnchors?.(snapshot) ?? [];
2306
+ const stale = [.../* @__PURE__ */ new Set([...snapshot.staleSources ?? [], ...unavailable])];
2307
+ const staleChanged = JSON.stringify(stale) !== JSON.stringify(current.staleSources ?? []);
2308
+ if (!current.sourceUnavailable && !staleChanged) return current;
2309
+ const projected = {
2310
+ ...current,
2311
+ sourceUnavailable: void 0
2312
+ };
2313
+ if (stale.length) projected.staleSources = stale;
2314
+ else delete projected.staleSources;
2315
+ return projected;
2316
+ }
2317
+ const { pins } = await this.providers.resolveTarget(snapshot.target, current.pins);
2318
+ if (JSON.stringify(current.pins) === JSON.stringify(pins) && !current.sourceUnavailable) return current;
2319
+ return this.providers.projectSource ? this.providers.projectSource(snapshot, pins) : {
2320
+ ...snapshot,
2321
+ pins,
2322
+ sourceUnavailable: void 0
2323
+ };
2324
+ }
2325
+ refreshPending;
2326
+ /** Refresh source state without writing authored document versions. Serialized with edits. */
2327
+ refreshWorktrees() {
2328
+ if (this.closing || !this.providers.resolveTarget) return Promise.resolve();
2329
+ if (this.refreshPending) return this.refreshPending;
2330
+ const run = this.pending.then(async () => {
2331
+ for (const summary of this.list()) {
2332
+ const snapshot = this.read(summary.reviewId, summary.version);
2333
+ try {
2334
+ const live = this.liveSources.get(summary.reviewId);
2335
+ const last = live?.version === snapshot.version ? live : snapshot;
2336
+ const previous = last.pins;
2337
+ const projected = await this.projectLiveSource(snapshot, last);
2338
+ if (projected === last) continue;
2339
+ this.liveSources.set(snapshot.reviewId, projected);
2340
+ if (JSON.stringify(previous) !== JSON.stringify(projected.pins) || JSON.stringify(last.staleSources ?? []) !== JSON.stringify(projected.staleSources ?? []) || last.sourceUnavailable) this.notify({
2341
+ reviewId: snapshot.reviewId,
2342
+ version: snapshot.version
2343
+ });
2344
+ } catch (error) {
2345
+ if (error instanceof ReviewInputError && error.status === 404) {
2346
+ const last = this.read(snapshot.reviewId);
2347
+ if (!last.sourceUnavailable) {
2348
+ this.liveSources.set(snapshot.reviewId, {
2349
+ ...last,
2350
+ sourceUnavailable: true
2351
+ });
2352
+ this.notify({
2353
+ reviewId: snapshot.reviewId,
2354
+ version: snapshot.version
2355
+ });
2356
+ }
2357
+ }
2358
+ }
2359
+ }
2360
+ });
2361
+ this.pending = run.catch(() => {});
2362
+ this.refreshPending = run.finally(() => {
2363
+ this.refreshPending = void 0;
2364
+ });
2365
+ return this.refreshPending;
2366
+ }
2367
+ listeners = /* @__PURE__ */ new Set();
2368
+ catalogListeners = /* @__PURE__ */ new Set();
2369
+ externalChanges;
2370
+ observedDataVersion;
2371
+ observedVersions = /* @__PURE__ */ new Map();
2372
+ subscribeCatalog(listener) {
2373
+ this.catalogListeners.add(listener);
2374
+ return () => {
2375
+ this.catalogListeners.delete(listener);
2376
+ };
2377
+ }
2378
+ /** For a host whose listing changed without the store: a preference flip. */
2379
+ invalidateCatalog() {
2380
+ for (const listener of this.catalogListeners) try {
2381
+ listener();
2382
+ } catch {}
2383
+ }
2384
+ subscribe(listener) {
2385
+ this.listeners.add(listener);
2386
+ return () => {
2387
+ this.listeners.delete(listener);
2388
+ };
2389
+ }
2390
+ constructor(databasePath, providers) {
2391
+ this.providers = providers;
2392
+ this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
2393
+ initializeReviewStoreSchema(this.db);
2394
+ this.activity = new ReviewActivity(this.db, (id) => this.assertExists(id));
2395
+ this.askHistory = new AskHistory(this.db);
2396
+ this.observedDataVersion = this.dataVersion();
2397
+ this.observedVersions = this.currentVersions();
2398
+ this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
2399
+ this.externalChanges.unref();
2400
+ }
2401
+ dataVersion() {
2402
+ return Number(this.db.prepare("PRAGMA data_version").get().data_version);
2403
+ }
2404
+ currentVersions() {
2405
+ return new Map(this.db.prepare("SELECT id,version FROM reviews").all().map((row) => [String(row.id), Number(row.version)]));
2406
+ }
2407
+ refreshExternalChanges() {
2408
+ const version = this.dataVersion();
2409
+ if (version === this.observedDataVersion) return;
2410
+ this.observedDataVersion = version;
2411
+ const current = this.currentVersions();
2412
+ const previous = this.observedVersions;
2413
+ this.observedVersions = current;
2414
+ for (const [reviewId, savedVersion] of current) if (previous.get(reviewId) !== savedVersion) this.notify({
2415
+ reviewId,
2416
+ version: savedVersion
2417
+ });
2418
+ for (const [reviewId, savedVersion] of previous) if (!current.has(reviewId)) {
2419
+ this.activity.deleted(reviewId);
2420
+ this.notify({
2421
+ reviewId,
2422
+ version: savedVersion,
2423
+ deleted: true
2424
+ });
2425
+ }
2426
+ this.activity.refresh();
2427
+ for (const listener of this.catalogListeners) try {
2428
+ listener();
2429
+ } catch {}
2430
+ }
2431
+ /** Reader progress never creates a document version or authoring event. */
2432
+ viewedCoverage(reviewId) {
2433
+ this.assertExists(reviewId);
2434
+ return new Map(this.db.prepare("SELECT file,fingerprint,coverage FROM review_coverage WHERE review_id=?").all(reviewId).map((row) => [String(row.file), {
2435
+ fingerprint: String(row.fingerprint),
2436
+ coverage: coverageSchema.parse(JSON.parse(String(row.coverage)))
2437
+ }]));
2438
+ }
2439
+ updateViewedCoverage(reviewId, files, viewed) {
2440
+ this.assertExists(reviewId);
2441
+ this.db.exec("BEGIN IMMEDIATE");
2442
+ try {
2443
+ const current = this.viewedCoverage(reviewId);
2444
+ for (const file of files) {
2445
+ const previous = current.get(file.path);
2446
+ const coverage = updateCoverage(previous?.fingerprint === file.fingerprint ? previous.coverage : emptyCoverage(), file.scope, viewed);
2447
+ this.db.prepare("INSERT OR REPLACE INTO review_coverage(review_id,file,fingerprint,coverage) VALUES(?,?,?,?)").run(reviewId, file.path, file.fingerprint, JSON.stringify(coverage));
2448
+ }
2449
+ this.db.exec("COMMIT");
2450
+ } catch (error) {
2451
+ this.db.exec("ROLLBACK");
2452
+ throw error;
2453
+ }
2454
+ }
2455
+ repositoryGroups = /* @__PURE__ */ new Map();
2456
+ repositoryGroup(root) {
2457
+ if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
2458
+ const context = resolveRepoContextSync(root);
2459
+ if (!context) return void 0;
2460
+ const group = context.githubSlug ? {
2461
+ key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
2462
+ label: context.githubSlug
2463
+ } : {
2464
+ key: `git:${context.commonDir}`,
2465
+ label: path.basename(path.dirname(context.commonDir))
2466
+ };
2467
+ this.repositoryGroups.set(root, group);
2468
+ return group;
2469
+ }
2470
+ registerRepository(root) {
2471
+ this.db.prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)").run(randomUUID(), root, root.split(/[\\/]/).at(-1));
2472
+ const row = this.db.prepare("SELECT id,name FROM repositories WHERE path=?").get(root);
2473
+ return {
2474
+ id: String(row.id),
2475
+ name: String(row.name)
2476
+ };
2477
+ }
2478
+ unregisterRepository(id) {
2479
+ this.db.prepare(`DELETE FROM repositories WHERE id=?
2480
+ AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
2481
+ AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`).run(id, `"repositoryId":${JSON.stringify(id)}`, id);
2482
+ }
2483
+ /** Registered checkouts, oldest registration first. */
2484
+ repositories() {
2485
+ return this.db.prepare("SELECT id,path FROM repositories ORDER BY rowid").all().map((row) => ({
2486
+ id: String(row.id),
2487
+ path: String(row.path)
2488
+ }));
2489
+ }
2490
+ repositoryPath(id) {
2491
+ const row = this.db.prepare("SELECT path FROM repositories WHERE id=?").get(id);
2492
+ if (!row) throw new ReviewInputError("Repository is not registered.", 404);
2493
+ return String(row.path);
2494
+ }
2495
+ putResource(id, repositoryId, kind, mimeType, data) {
2496
+ this.db.prepare("INSERT OR IGNORE INTO resources(id,repository_id,kind,mime_type,data) VALUES(?,?,?,?,?)").run(id, repositoryId, kind, mimeType, data);
2497
+ const saved = this.resource(id);
2498
+ if (saved.repositoryId !== repositoryId || saved.kind !== kind || saved.mimeType !== mimeType || !Buffer.from(saved.data).equals(data)) throw new ReviewInputError("Resource ID was already used for different content.", 409);
2499
+ return {
2500
+ id,
2501
+ kind,
2502
+ mimeType
2503
+ };
2504
+ }
2505
+ resource(id) {
2506
+ const row = this.db.prepare("SELECT * FROM resources WHERE id=?").get(id);
2507
+ if (!row) throw new ReviewInputError("Resource not found.", 404);
2508
+ return {
2509
+ id,
2510
+ repositoryId: String(row.repository_id),
2511
+ kind: String(row.kind),
2512
+ mimeType: String(row.mime_type),
2513
+ data: row.data
2514
+ };
2515
+ }
2516
+ async close() {
2517
+ this.closing = true;
2518
+ clearInterval(this.externalChanges);
2519
+ clearInterval(this.refreshTimer);
2520
+ await this.pending;
2521
+ this.listeners.clear();
2522
+ this.catalogListeners.clear();
2523
+ this.activity.close();
2524
+ this.db.close();
2525
+ }
2526
+ /** Stable across restarts; the first host on a new store chooses it. */
2527
+ serverId() {
2528
+ const read = () => this.db.prepare("SELECT id FROM server_identity").get()?.id;
2529
+ let id = read();
2530
+ if (id === void 0) {
2531
+ this.db.prepare("INSERT OR IGNORE INTO server_identity(one,id) VALUES(1,?)").run(randomUUID());
2532
+ id = read();
2533
+ }
2534
+ return String(id);
2535
+ }
2536
+ resetServerId() {
2537
+ const id = randomUUID();
2538
+ this.db.prepare("INSERT OR REPLACE INTO server_identity(one,id) VALUES(1,?)").run(id);
2539
+ return id;
2540
+ }
2541
+ /** The 404 check alone, without loading a snapshot. */
2542
+ assertExists(id) {
2543
+ if (!this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(id)) throw new ReviewInputError("Review not found. If this is an old Whiteboard review, ask your agent to migrate your old Whiteboard reviews.", 404);
2544
+ }
2545
+ read(id, version) {
2546
+ const row = version === void 0 ? this.db.prepare("SELECT snapshot FROM versions JOIN reviews ON reviews.id=review_id AND reviews.version=versions.version WHERE reviews.id=?").get(id) : this.db.prepare("SELECT snapshot FROM versions WHERE review_id=? AND version=?").get(id, version);
2547
+ if (!row) {
2548
+ this.assertExists(id);
2549
+ throw new ReviewInputError("Review version not found.", 404);
2550
+ }
2551
+ const snapshot = JSON.parse(String(row.snapshot));
2552
+ const { document, lenses } = liftFileLenses(migrateStoredDocument(snapshot.document));
2553
+ snapshot.document = document;
2554
+ if (snapshot.lenses) snapshot.lenses = migrateStoredLenses(snapshot.lenses);
2555
+ if (lenses.length) snapshot.lenses = [...snapshot.lenses ?? [], ...lenses];
2556
+ if (snapshot.pins) snapshot.target ??= {
2557
+ kind: "commits",
2558
+ repositoryId: snapshot.pins.repositoryId,
2559
+ base: snapshot.pins.base,
2560
+ head: snapshot.pins.head
2561
+ };
2562
+ const live = version === void 0 ? this.liveSources.get(id) : void 0;
2563
+ if (live?.version === snapshot.version) return structuredClone(live);
2564
+ return snapshot;
2565
+ }
2566
+ setDiffStats(pins, stats, mode = "structural") {
2567
+ if (this.closing) return;
2568
+ this.db.prepare("INSERT OR REPLACE INTO comparison_stats(identity, stats) VALUES (?, ?)").run(JSON.stringify([pins, mode]), JSON.stringify(stats));
2569
+ for (const listener of this.catalogListeners) try {
2570
+ listener();
2571
+ } catch {}
2572
+ }
2573
+ /** Managed records are discoverable even if their preparation stamp was lost. */
2574
+ tutorialIds() {
2575
+ return this.db.prepare(`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`).all().map((row) => String(row.id));
2576
+ }
2577
+ list(mode = "structural") {
2578
+ return this.summaries(mode);
2579
+ }
2580
+ /** One review's catalog entry, as review_list shows it. */
2581
+ summary(id) {
2582
+ return this.summaries("structural", id)[0];
2583
+ }
2584
+ summaries(mode, id) {
2585
+ const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
2586
+ (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,
2587
+ review_attention.viewed_at, review_attention.dismissed_at, repositories.name AS repository_name, repositories.path AS repository_path
2588
+ FROM reviews
2589
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2590
+ LEFT JOIN review_attention ON review_attention.review_id=reviews.id
2591
+ LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
2592
+ WHERE COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
2593
+ ${id === void 0 ? "" : "AND reviews.id=?"}
2594
+ ORDER BY reviews.rowid`).all(...id === void 0 ? [] : [id]).map((row) => {
2595
+ const summary = JSON.parse(String(row.summary));
2596
+ if (summary.pins) summary.target ??= {
2597
+ kind: "commits",
2598
+ repositoryId: summary.pins.repositoryId,
2599
+ base: summary.pins.base,
2600
+ head: summary.pins.head
2601
+ };
2602
+ const live = this.liveSources.get(summary.reviewId);
2603
+ if (live?.version === summary.version && summary.target?.kind === "worktree") summary.pins = live.pins;
2604
+ const listed = {
2605
+ ...summary,
2606
+ firstCreatedAt: row.first_created_at ? String(row.first_created_at) : void 0,
2607
+ repositoryPath: row.repository_path ? String(row.repository_path) : void 0,
2608
+ repositoryGroup: row.repository_path ? this.repositoryGroup(String(row.repository_path)) : void 0,
2609
+ repositoryName: row.repository_name ? String(row.repository_name) : summary.pins?.repositoryId ?? "",
2610
+ viewedAt: row.viewed_at ? String(row.viewed_at) : null,
2611
+ dismissedAt: row.dismissed_at ? String(row.dismissed_at) : null,
2612
+ working: this.activity.isWorking(summary.reviewId)
2613
+ };
2614
+ if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
2615
+ return listed;
2616
+ });
2617
+ return this.withDiffStats(reviews, mode);
2618
+ }
2619
+ /** Local and imported summaries use the same persisted, mode-specific counts. */
2620
+ withDiffStats(reviews, mode = "structural") {
2621
+ const stats = new Map(this.db.prepare("SELECT identity, stats FROM comparison_stats").all().map((row) => [String(row.identity), JSON.parse(String(row.stats))]));
2622
+ return reviews.map((review) => ({
2623
+ ...review,
2624
+ diffStats: review.pins ? stats.get(JSON.stringify([review.pins, mode])) ?? null : null
2625
+ }));
2626
+ }
2627
+ /** What the pad holds, for its Home card: blocks, and the diagrams among them. */
2628
+ scratchpadContents() {
2629
+ const blocks = elements(this.read(SCRATCHPAD_ID).document).filter((element) => !isUnit(element));
2630
+ return {
2631
+ blocks: blocks.length,
2632
+ diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length
2633
+ };
2634
+ }
2635
+ /** The one scratchpad, made on first use. A host that loses the race to
2636
+ * another on the same home finds it made. */
2637
+ async ensureScratchpad() {
2638
+ if (this.has(SCRATCHPAD_ID)) return;
2639
+ try {
2640
+ await this.execute({ operation: {
2641
+ type: "create",
2642
+ title: SCRATCHPAD_TITLE,
2643
+ kind: "scratchpad"
2644
+ } });
2645
+ } catch (error) {
2646
+ if (!this.has(SCRATCHPAD_ID)) throw error;
2647
+ }
2648
+ }
2649
+ history(id) {
2650
+ return this.db.prepare("SELECT version,json_extract(snapshot,'$.title') AS title,json_extract(snapshot,'$.createdAt') AS created_at FROM versions WHERE review_id=? ORDER BY version").all(id).map((row) => ({
2651
+ version: Number(row.version),
2652
+ title: String(row.title),
2653
+ createdAt: String(row.created_at)
2654
+ }));
2655
+ }
2656
+ inspect(id, targetId, version) {
2657
+ return inspectSnapshot(this.read(id, version), targetId);
2658
+ }
2659
+ /** The host can seed a managed document; transport callers only supply a command. */
2660
+ execute(input, initial) {
2661
+ if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
2662
+ const command = commandSchema.parse(input);
2663
+ if (initial && command.operation.type !== "create") throw new ReviewInputError("Initial content requires a create command.");
2664
+ const pullRequest = this.startPullRequest(command);
2665
+ pullRequest?.catch(() => {});
2666
+ const run = this.pending.then(async () => {
2667
+ const op = command.operation;
2668
+ if (op.type !== "create" && op.type !== "edit" && op.type !== "lens_edit" && op.type !== "restore" && this.read(op.reviewId).kind === "scratchpad") throw new ReviewInputError("The scratchpad has no lifecycle, title or pins of its own.", 409);
2669
+ if (op.type === "create" && op.kind === "scratchpad") {
2670
+ if (op.target) throw new ReviewInputError("A scratchpad has no target of its own.");
2671
+ if (this.has(SCRATCHPAD_ID)) throw new ReviewInputError("The scratchpad already exists.", 409);
2672
+ } else if (op.type === "create") {
2673
+ if (!op.target && !op.pullRequestUrl) throw new ReviewInputError("Supply a target or a pullRequestUrl.");
2674
+ if (op.repositoryId && op.target) throw new ReviewInputError("A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.");
2675
+ if (!op.title && op.target) throw new ReviewInputError("Supply a title.");
2676
+ }
2677
+ const requestedTarget = op.type === "create" || op.type === "set_target" ? op.target : void 0;
2678
+ const fromPullRequest = await pullRequest;
2679
+ const resolvedTarget = requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
2680
+ if (requestedTarget && !resolvedTarget) throw new ReviewInputError("Review targets are unavailable.");
2681
+ if (op.type === "create" && op.pullRequestUrl && op.reuseExisting !== false) {
2682
+ const [found, ...others] = this.reviewsForPullRequest(op.pullRequestUrl);
2683
+ if (found) return this.existingReview(found, others, resolvedTarget.pins);
2684
+ }
2685
+ if (op.type === "delete") {
2686
+ const result = {
2687
+ reviewId: op.reviewId,
2688
+ version: this.read(op.reviewId).version,
2689
+ deleted: true
2690
+ };
2691
+ this.commitCommand(result, () => {
2692
+ for (const table of [
2693
+ "ask_conversations",
2694
+ "authoring_presences",
2695
+ "review_coverage",
2696
+ "review_attention",
2697
+ "versions"
2698
+ ]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(op.reviewId);
2699
+ this.db.prepare("DELETE FROM reviews WHERE id=?").run(op.reviewId);
2700
+ }, () => this.assertMutation(op.reviewId, result.version));
2701
+ return result;
2702
+ }
2703
+ if (op.type === "attention") {
2704
+ const result = {
2705
+ reviewId: op.reviewId,
2706
+ version: this.read(op.reviewId).version,
2707
+ attention: true
2708
+ };
2709
+ this.commitCommand(result, () => {
2710
+ this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(op.reviewId);
2711
+ if (op.action === "view") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
2712
+ else this.db.prepare("UPDATE review_attention SET dismissed_at=? WHERE review_id=?").run(op.action === "dismiss" ? (/* @__PURE__ */ new Date()).toISOString() : null, op.reviewId);
2713
+ });
2714
+ return result;
2715
+ }
2716
+ const id = op.type !== "create" ? op.reviewId : op.kind === "scratchpad" ? SCRATCHPAD_ID : randomUUID();
2717
+ const previous = op.type === "create" ? void 0 : this.read(id);
2718
+ let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title) : structuredClone(previous);
2719
+ delete snapshot.sourceUnavailable;
2720
+ delete snapshot.lastEdit;
2721
+ let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
2722
+ let applied;
2723
+ let lensTarget;
2724
+ if ((op.type === "create" || op.type === "set_target") && this.providers.headBranch) {
2725
+ const pins = resolvedTarget?.pins ?? snapshot.pins;
2726
+ if (pins) {
2727
+ const headRef = requestedTarget?.kind === "commits" ? requestedTarget.head : void 0;
2728
+ const branch = await this.providers.headBranch(pins, headRef);
2729
+ snapshot.origin = {
2730
+ ...snapshot.origin,
2731
+ branch
2732
+ };
2733
+ }
2734
+ }
2735
+ switch (op.type) {
2736
+ case "create":
2737
+ if (initial) {
2738
+ snapshot.document = documentSchema.parse(initial.document);
2739
+ snapshot.origin = {
2740
+ ...snapshot.origin,
2741
+ ...structuredClone(initial.origin)
2742
+ };
2743
+ for (const block of snapshot.document) assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
2744
+ }
2745
+ setPullRequest(snapshot, op.pullRequestUrl);
2746
+ break;
2747
+ case "rename":
2748
+ snapshot.title = op.title;
2749
+ break;
2750
+ case "set_target":
2751
+ setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.pins?.repositoryId !== resolvedTarget.pins.repositoryId ? null : void 0));
2752
+ snapshot.staleSources = [];
2753
+ snapshot.target = resolvedTarget.target;
2754
+ snapshot.pins = resolvedTarget.pins;
2755
+ break;
2756
+ case "restore":
2757
+ snapshot = this.read(id, op.version);
2758
+ delete snapshot.lastEdit;
2759
+ break;
2760
+ case "lens_edit": {
2761
+ if (snapshot.kind === "scratchpad") throw new ReviewInputError("The scratchpad has no changes of its own to lens.", 409);
2762
+ const lenses = snapshot.lenses ?? [];
2763
+ const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
2764
+ if (lenses.length) snapshot.lenses = lenses;
2765
+ else delete snapshot.lenses;
2766
+ lensTarget = {
2767
+ targetId: lens.id,
2768
+ type: "lens"
2769
+ };
2770
+ snapshot.lastEdit = {
2771
+ type: op.edit.type,
2772
+ targetId: lens.id,
2773
+ blockId: lens.id,
2774
+ kind: "lens",
2775
+ ...op.edit.type === "update" && { fields: Object.keys(op.edit).filter((key) => key !== "type" && key !== "targetId") }
2776
+ };
2777
+ break;
2778
+ }
2779
+ case "edit":
2780
+ applied = applyEdit(snapshot.document, op.edit, (prefix) => `${prefix}-${++nextId}`, { placement: snapshot.kind === "scratchpad" ? "first" : "last" });
2781
+ snapshot.lastEdit = summarizeEdit(op.edit, applied, previous.document, snapshot.document);
2782
+ if (snapshot.staleSources?.length) {
2783
+ const oldSources = new Map(sourceReferences(previous.document).map((item) => [item.id, JSON.stringify(item.source)]));
2784
+ const newSources = new Map(sourceReferences(snapshot.document).map((item) => [item.id, JSON.stringify(item.source)]));
2785
+ snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
2786
+ }
2787
+ }
2788
+ if (snapshot.target?.kind === "worktree" && op.type !== "restore" && !resolvedTarget) snapshot = await this.projectLiveSource(snapshot);
2789
+ checkReferences(snapshot.document);
2790
+ if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
2791
+ const warnings = await this.validateExternal(snapshot, previous, op.type === "set_target");
2792
+ snapshot.version = previous ? previous.version + 1 : 0;
2793
+ snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
2794
+ const result = {
2795
+ ...op.type === "create" && { created: true },
2796
+ reviewId: id,
2797
+ version: snapshot.version,
2798
+ targetId: applied?.targetId ?? lensTarget?.targetId,
2799
+ ...applied && { type: applied.type },
2800
+ ...lensTarget && { type: lensTarget.type },
2801
+ ...applied?.children && { children: applied.children }
2802
+ };
2803
+ if (snapshot.staleSources?.length) warnings.push("Some authored source ranges changed. Update their references before presenting this review.");
2804
+ if (warnings.length) result.warnings = warnings;
2805
+ this.commitCommand(result, () => {
2806
+ this.db.prepare("INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id").run(id, snapshot.version, nextId);
2807
+ this.db.prepare("INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot));
2808
+ }, previous ? () => this.assertMutation(id, previous.version) : void 0, op.type === "edit" || op.type === "lens_edit" ? {
2809
+ surface: op.type === "edit" ? "document" : "lenses",
2810
+ activityId: op.activityId,
2811
+ owned: (owner) => {
2812
+ if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
2813
+ }
2814
+ } : void 0);
2815
+ return result;
2816
+ });
2817
+ this.pending = run.catch(() => {});
2818
+ return run;
2819
+ }
2820
+ /** Resolve a target-less PR create before it queues, when it will need it. */
2821
+ startPullRequest(command) {
2822
+ const op = command.operation;
2823
+ if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl) return void 0;
2824
+ if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
2825
+ const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
2826
+ return this.providers.resolvePullRequest(op.pullRequestUrl, {
2827
+ id: op.repositoryId,
2828
+ preferred: existing && this.read(existing).pins?.repositoryId
2829
+ });
2830
+ }
2831
+ /** Reviews whose PR is this one, newest version first. Summaries only. */
2832
+ reviewsForPullRequest(url) {
2833
+ return this.db.prepare(`SELECT reviews.id FROM reviews
2834
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2835
+ WHERE lower(json_extract(versions.snapshot,'$.origin.pullRequestUrl'))=?
2836
+ AND COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
2837
+ ORDER BY json_extract(versions.snapshot,'$.createdAt') DESC, reviews.rowid DESC`).all(pullRequestKey(url)).map((row) => String(row.id));
2838
+ }
2839
+ /** The answer to a create that found its PR's review. Its target stays:
2840
+ * moving it would silently point existing links at different code. */
2841
+ existingReview(reviewId, others, requested) {
2842
+ const snapshot = this.read(reviewId);
2843
+ const headMoved = snapshot.pins?.repositoryId !== requested.repositoryId || snapshot.pins?.head !== requested.head;
2844
+ const working = this.activity.read(reviewId).activities ?? [];
2845
+ return {
2846
+ created: false,
2847
+ note: [
2848
+ "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.",
2849
+ headMoved && "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.",
2850
+ working.length > 0 && `Another agent is working on it${working[0].focus ? `: ${working[0].focus.description}` : ""}.`,
2851
+ others.length > 0 && "Older reviews also name this PR; see otherReviewIds."
2852
+ ].filter(Boolean).join(" "),
2853
+ reviewId,
2854
+ version: snapshot.version,
2855
+ target: snapshot.target,
2856
+ headMoved,
2857
+ ...working.length > 0 && { working: true },
2858
+ ...others.length > 0 && { otherReviewIds: others }
2859
+ };
2860
+ }
2861
+ commitCommand(result, apply, guard, attribution) {
2862
+ this.db.exec("BEGIN IMMEDIATE");
2863
+ let owner;
2864
+ try {
2865
+ guard?.();
2866
+ if (attribution) {
2867
+ owner = this.activity.attribute(result.reviewId, attribution.surface, attribution.activityId);
2868
+ if (owner) attribution.owned(owner);
2869
+ else if (attribution.activityId) result.warnings = [...result.warnings ?? [], "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."];
2870
+ }
2871
+ apply();
2872
+ this.db.exec("COMMIT");
2873
+ } catch (error) {
2874
+ this.db.exec("ROLLBACK");
2875
+ throw error;
2876
+ }
2877
+ if (result.deleted) this.activity.deleted(result.reviewId);
2878
+ else if (owner) this.activity.extended(result.reviewId);
2879
+ this.notify(result);
2880
+ }
2881
+ assertMutation(reviewId, version) {
2882
+ const current = this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId);
2883
+ if ((current ? Number(current.version) : void 0) !== version) throw new ReviewInputError("Review changed during validation. Reread it and retry the edit.", 409);
2884
+ }
2885
+ notify(result) {
2886
+ if (result.deleted) this.observedVersions.delete(result.reviewId);
2887
+ else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
2888
+ if (!result.attention) for (const listener of this.listeners) try {
2889
+ listener(result);
2890
+ } catch {}
2891
+ for (const listener of this.catalogListeners) try {
2892
+ listener();
2893
+ } catch {}
2894
+ }
2895
+ /** Dismissed reviews, as Home lists them; only a restore clears it, not a view. */
2896
+ dismissedIds() {
2897
+ return this.db.prepare("SELECT review_id FROM review_attention WHERE dismissed_at IS NOT NULL").all().map((row) => String(row.review_id));
2898
+ }
2899
+ has(reviewId) {
2900
+ return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
2901
+ }
2902
+ async validateExternal(snapshot, previous, repin = false) {
2903
+ const warnings = [];
2904
+ const references = (document, tolerant = false, lenses = []) => {
2905
+ const sources = /* @__PURE__ */ new Map();
2906
+ const resources = /* @__PURE__ */ new Map();
2907
+ const add = (source, peek) => {
2908
+ const key = JSON.stringify(source);
2909
+ const kept = sources.get(key);
2910
+ sources.set(key, {
2911
+ source,
2912
+ peek: peek || (kept?.peek ?? false)
2913
+ });
2914
+ };
2915
+ for (const { source, peek } of sourceReferences(document, { tolerant })) add(source, peek === true);
2916
+ for (const { source } of lensSelections(lenses)) for (const anchor of sourceAnchors(source)) add(anchor, false);
2917
+ for (const block of resourceReferences(document)) resources.set(JSON.stringify(block), block);
2918
+ return {
2919
+ sources,
2920
+ resources
2921
+ };
2922
+ };
2923
+ const current = references(snapshot.document, repin, snapshot.lenses);
2924
+ const pinsChanged = previous && JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
2925
+ const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
2926
+ const changed = repin && pinsChanged && previous.pins && snapshot.pins ? await this.providers.filesChangedBetween?.(previous.pins, snapshot.pins).catch(() => void 0) : void 0;
2927
+ const retained = references(previous?.document ?? [], true, previous?.lenses);
2928
+ const checks = [];
2929
+ const retainedPins = new Set(explicitPins([...retained.sources.values()]).map((pins) => JSON.stringify(pins)));
2930
+ for (const pins of explicitPins([...current.sources.values()])) if (!retainedPins.has(JSON.stringify(pins))) checks.push(this.providers.validatePins(pins));
2931
+ for (const [key, { source, peek }] of current.sources) {
2932
+ const kept = retained.sources.get(key);
2933
+ if (pinsChanged && !source.pins || !kept || peek && !kept.peek) checks.push(this.providers.validateSource(anchorPins(source, snapshot.pins), source, { peek }).then(() => {
2934
+ if (repin && (!changed || changed[source.side].has(source.file))) warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: source pins changed; verify that this range still supports the document.`);
2935
+ }, (error) => {
2936
+ if (!repin && !(worktreeMoved && kept) || !(error instanceof ReviewInputError)) throw error;
2937
+ warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`);
2938
+ }));
2939
+ }
2940
+ for (const [key, block] of current.resources) if (pinsChanged || !retained.resources.has(key)) checks.push(this.providers.validateResource(snapshot.pins, block).catch((error) => {
2941
+ if (!repin && !(worktreeMoved && retained.resources.has(key)) || !(error instanceof ReviewInputError)) throw error;
2942
+ warnings.push(`${block.id} (${block.type}): ${error.message}`);
2943
+ }));
2944
+ await Promise.all(checks);
2945
+ return warnings.sort();
2946
+ }
2947
+ };
2948
+ /** Without a resolver, a commits target that names both commits pins them as
2949
+ * given; validatePins still checks they exist. */
2950
+ function namedCommits(target) {
2951
+ if (target.kind !== "commits" || target.base === void 0) return void 0;
2952
+ const { repositoryId, base, head } = target;
2953
+ return {
2954
+ target,
2955
+ pins: {
2956
+ repositoryId,
2957
+ base,
2958
+ head
2959
+ }
2960
+ };
2961
+ }
2962
+ /** A new document's first version, before its initial content. Field order
2963
+ * is kept so stored JSON reads as it always has. */
2964
+ function createdSnapshot(id, op, resolved, defaultTitle) {
2965
+ const title = op.title ?? defaultTitle;
2966
+ if (!title) throw new ReviewInputError("Supply a title.");
2967
+ if (!resolved) return {
2968
+ reviewId: id,
2969
+ version: 0,
2970
+ title,
2971
+ kind: op.kind,
2972
+ document: [],
2973
+ createdAt: ""
2974
+ };
2975
+ return {
2976
+ reviewId: id,
2977
+ version: 0,
2978
+ title,
2979
+ pins: resolved.pins,
2980
+ target: resolved.target,
2981
+ document: [],
2982
+ createdAt: ""
2983
+ };
2984
+ }
2985
+ function inspectSnapshot(snapshot, targetId) {
2986
+ if (targetId !== void 0) {
2987
+ const target = elements(snapshot.document).find((element) => element.id === targetId);
2988
+ if (!target) throw new ReviewInputError("Target not found in this version.", 404);
2989
+ return target;
2990
+ }
2991
+ return elements(snapshot.document).map((element) => ({
2992
+ id: element.id,
2993
+ type: element.type,
2994
+ label: "title" in element ? element.title : "label" in element ? element.label : element.type === "markdown" ? element.markdown.slice(0, 120) : void 0
2995
+ }));
2996
+ }
2997
+ //#endregion
2998
+ //#region src/review-api/workspaces.ts
2999
+ const OWNED_ELSEWHERE = "Another Desktop owns this review's language workspaces. Quit that Desktop, then retry.";
3000
+ /** Local lifecycle only: source and authored history never depend on preparation.
3001
+ * Status/queue/process lifecycle follows #334, retaining the legacy prepare config
3002
+ * and per-review managed checkout layout instead of a new settings system.
3003
+ */
3004
+ var ReviewWorkspaces = class {
3005
+ store;
3006
+ db;
3007
+ requests = /* @__PURE__ */ new Map();
3008
+ jobs = /* @__PURE__ */ new Map();
3009
+ stop;
3010
+ cleanup = Promise.resolve();
3011
+ closed = false;
3012
+ ownerId = randomUUID();
3013
+ constructor(databasePath, store) {
3014
+ this.store = store;
3015
+ this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
3016
+ this.db.exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS pinned_environments(id TEXT PRIMARY KEY, value TEXT NOT NULL)");
3017
+ this.db.exec("DROP TABLE IF EXISTS workspace_owner; CREATE TABLE IF NOT EXISTS workspace_leases(review_id TEXT PRIMARY KEY,owner TEXT NOT NULL,pid INTEGER NOT NULL)");
3018
+ this.db.exec("BEGIN IMMEDIATE");
3019
+ try {
3020
+ for (const environment of this.all()) {
3021
+ if (this.leasedElsewhere(environment.reviewId)) continue;
3022
+ if (environment.state === "preparing") environment.state = "pending";
3023
+ environment.generation = randomUUID();
3024
+ this.save(environment);
3025
+ }
3026
+ this.db.exec("COMMIT");
3027
+ } catch (error) {
3028
+ this.db.exec("ROLLBACK");
3029
+ this.db.close();
3030
+ throw error;
3031
+ }
3032
+ this.stop = store.subscribeCatalog(() => {
3033
+ this.collect();
3034
+ this.releaseDismissed();
3035
+ });
3036
+ this.collect();
3037
+ this.releaseDismissed();
3038
+ }
3039
+ dismissed;
3040
+ releasing = /* @__PURE__ */ new Map();
3041
+ /**
3042
+ * Frees a newly dismissed review's checkouts to save disk. The review itself
3043
+ * stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
3044
+ * dismissed review are kept until the next startup or dismissal, since
3045
+ * the Desktop's canvas tabs and source windows may still show them.
3046
+ */
3047
+ releaseDismissed() {
3048
+ const dismissed = new Set(this.store.dismissedIds());
3049
+ const released = [...dismissed].filter((id) => !this.dismissed?.has(id));
3050
+ this.dismissed = dismissed;
3051
+ if (!released.length) return;
3052
+ const repositories = this.repositoryDirs();
3053
+ repositories.catch(() => void 0);
3054
+ for (const reviewId of released) this.release(reviewId, repositories);
3055
+ }
3056
+ async repositoryDirs() {
3057
+ const dirs = new Set(this.all().map((item) => item.repository));
3058
+ for (const { path: root } of this.store.repositories()) {
3059
+ const common = await gitCommonDir(root).catch(() => null);
3060
+ if (common) dirs.add(common);
3061
+ }
3062
+ return [...dirs].filter(Boolean);
3063
+ }
3064
+ /** Resolves once no release of the review is pending, so an ensure cannot race it. */
3065
+ released(reviewId) {
3066
+ return this.releasing.get(reviewId) ?? Promise.resolve();
3067
+ }
3068
+ release(reviewId, repositories = this.repositoryDirs()) {
3069
+ const requests = [...this.requests.values()];
3070
+ const done = this.cleanup.then(async () => {
3071
+ await Promise.allSettled(requests);
3072
+ await this.releaseCheckouts(reviewId, await repositories);
3073
+ }).catch((error) => {
3074
+ console.error(`Could not free the pinned checkouts of dismissed review ${reviewId}:`, error);
3075
+ }).finally(() => {
3076
+ if (this.releasing.get(reviewId) === done) this.releasing.delete(reviewId);
3077
+ });
3078
+ this.releasing.set(reviewId, done);
3079
+ this.cleanup = done;
3080
+ }
3081
+ async releaseCheckouts(reviewId, repositories) {
3082
+ if (!this.store.dismissedIds().includes(reviewId)) return;
3083
+ const environments = this.all().filter((item) => item.reviewId === reviewId);
3084
+ const checkouts = repositories.filter((repository) => existsSync(reviewManagedCheckoutRoot(repository, reviewId)));
3085
+ if (!environments.length && !checkouts.length || !this.claim(reviewId)) return;
3086
+ for (const environment of environments) {
3087
+ const job = this.jobs.get(environment.id);
3088
+ job?.abort.abort();
3089
+ await job?.done;
3090
+ }
3091
+ const errors = [];
3092
+ for (const repository of checkouts) await removeReviewManagedCheckouts(repository, reviewId).catch((error) => errors.push(errorMessage(error)));
3093
+ for (const environment of environments) if (errors.length && environment.rootPath && existsSync(environment.rootPath)) {
3094
+ environment.state = "cleanup-failed";
3095
+ environment.log = errors.join("\n");
3096
+ this.save(environment);
3097
+ } else this.db.prepare("DELETE FROM pinned_environments WHERE id=?").run(environment.id);
3098
+ this.db.prepare("DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)").run(reviewId, this.ownerId, reviewId);
3099
+ if (errors.length) throw new Error(errors.join("\n"));
3100
+ }
3101
+ external;
3102
+ stopExternal;
3103
+ attachExternalReviews(source) {
3104
+ if (this.external === source) return;
3105
+ this.stopExternal?.();
3106
+ this.external = source;
3107
+ this.stopExternal = source.subscribe(() => this.collect());
3108
+ this.collect();
3109
+ }
3110
+ hasReview(id) {
3111
+ if (id.startsWith("shared-") && !this.external) return true;
3112
+ return this.store.has(id) || Boolean(this.external?.has(id));
3113
+ }
3114
+ assertReview(id) {
3115
+ if (!this.hasReview(id)) this.store.assertExists(id);
3116
+ }
3117
+ async remove(reviewId) {
3118
+ if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
3119
+ await Promise.all(this.requests.values());
3120
+ this.collect(void 0, reviewId);
3121
+ await this.cleanup;
3122
+ if (this.all().some((item) => item.reviewId === reviewId)) throw new ReviewInputError("Could not remove the managed workspace. Retry deletion.", 409);
3123
+ }
3124
+ lease(reviewId) {
3125
+ return this.db.prepare("SELECT owner,pid FROM workspace_leases WHERE review_id=?").get(reviewId);
3126
+ }
3127
+ leasedElsewhere(reviewId) {
3128
+ const lease = this.lease(reviewId);
3129
+ return Boolean(lease && lease.owner !== this.ownerId && processIsAlive(Number(lease.pid)));
3130
+ }
3131
+ /** Takes the review's lease unless another live Desktop holds it. */
3132
+ claim(reviewId) {
3133
+ if (this.lease(reviewId)?.owner === this.ownerId) return true;
3134
+ this.db.exec("BEGIN IMMEDIATE");
3135
+ try {
3136
+ const free = !this.leasedElsewhere(reviewId);
3137
+ if (free) this.db.prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?)").run(reviewId, this.ownerId, process.pid);
3138
+ this.db.exec("COMMIT");
3139
+ return free;
3140
+ } catch (error) {
3141
+ this.db.exec("ROLLBACK");
3142
+ throw error;
3143
+ }
3144
+ }
3145
+ all() {
3146
+ return this.db.prepare("SELECT value FROM pinned_environments").all().map((row) => JSON.parse(String(row.value)));
3147
+ }
3148
+ get(id) {
3149
+ const row = this.db.prepare("SELECT value FROM pinned_environments WHERE id=?").get(id);
3150
+ return row ? JSON.parse(String(row.value)) : void 0;
3151
+ }
3152
+ save(environment) {
3153
+ this.db.prepare("INSERT INTO pinned_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value").run(environment.id, JSON.stringify(environment));
3154
+ }
3155
+ status(environment) {
3156
+ const { id, commit, rootPath, generation, state, log } = environment;
3157
+ const status = {
3158
+ id,
3159
+ commit,
3160
+ rootPath,
3161
+ generation,
3162
+ state,
3163
+ log
3164
+ };
3165
+ if (state === "failed" && !rootPath) status.issue = log;
3166
+ return status;
3167
+ }
3168
+ list(reviewId) {
3169
+ return this.all().filter((item) => item.reviewId === reviewId).map((item) => this.status(item));
3170
+ }
3171
+ failures() {
3172
+ return this.all().filter((item) => item.state === "cleanup-failed").map((item) => this.status(item));
3173
+ }
3174
+ async retryCleanup(id) {
3175
+ const environment = this.get(id);
3176
+ if (!environment || environment.state !== "cleanup-failed") throw new ReviewInputError("Cleanup failure not found.", 404);
3177
+ if (this.store.has(environment.reviewId)) this.release(environment.reviewId);
3178
+ else this.collect(id);
3179
+ await this.cleanup;
3180
+ }
3181
+ async open(reviewId, pins) {
3182
+ await this.source(reviewId, pins, "head");
3183
+ if (pins.base !== pins.head) await this.source(reviewId, pins, "base");
3184
+ }
3185
+ source(reviewId, pins, side, retryFailed = false) {
3186
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("Language environments are closed."));
3187
+ this.assertReview(reviewId);
3188
+ const id = createHash("sha256").update(JSON.stringify([
3189
+ reviewId,
3190
+ pins.repositoryId,
3191
+ pins[side]
3192
+ ])).digest("hex");
3193
+ const current = this.requests.get(id);
3194
+ if (current) return current;
3195
+ if (!this.claim(reviewId)) {
3196
+ const environment = this.get(id);
3197
+ return Promise.resolve(environment ? this.status(environment) : {
3198
+ id,
3199
+ commit: pins[side],
3200
+ rootPath: null,
3201
+ generation: "",
3202
+ state: "pending",
3203
+ log: OWNED_ELSEWHERE
3204
+ });
3205
+ }
3206
+ const request = this.acquire(id, reviewId, pins, side, retryFailed).finally(() => this.requests.delete(id));
3207
+ this.requests.set(id, request);
3208
+ return request;
3209
+ }
3210
+ async acquire(id, reviewId, pins, side, retryFailed) {
3211
+ await this.released(reviewId);
3212
+ let environment = this.get(id);
3213
+ if (this.jobs.has(id)) return this.status(environment);
3214
+ environment ??= {
3215
+ id,
3216
+ reviewId,
3217
+ repositoryId: pins.repositoryId,
3218
+ repository: "",
3219
+ commit: pins[side],
3220
+ rootPath: null,
3221
+ generation: randomUUID(),
3222
+ commandsHash: "",
3223
+ role: pins.base === pins.head ? "head" : side,
3224
+ state: "pending",
3225
+ log: ""
3226
+ };
3227
+ this.save(environment);
3228
+ try {
3229
+ const root = this.store.repositoryPath(pins.repositoryId);
3230
+ const repository = environment.repository || await gitCommonDir(root).catch(() => null);
3231
+ if (!repository) throw new Error("Repository Git directory is unavailable. Restore the registered checkout and retry preparation.");
3232
+ environment.repository = repository;
3233
+ const result = await git(repository, [
3234
+ "config",
3235
+ "--null",
3236
+ "--get-all",
3237
+ "devfast.prepare"
3238
+ ], { allowFailure: true });
3239
+ const commands = result.stdout.trim() ? result.stdout.split("\0").filter((command) => command.trim()) : [];
3240
+ const commandsHash = reviewPrepareCommandsHash(commands);
3241
+ const existed = environment.rootPath && existsSync(path.join(environment.rootPath, ".git"));
3242
+ const checkout = await ensureReviewPinnedCheckout({
3243
+ rootPath: existsSync(root) ? root : repository,
3244
+ reviewUuid: reviewId,
3245
+ ref: pins[side],
3246
+ role: environment.role
3247
+ });
3248
+ if (!checkout) throw new Error("Pinned checkout is unavailable.");
3249
+ if (!this.hasReview(reviewId)) {
3250
+ environment.rootPath = checkout;
3251
+ this.save(environment);
3252
+ this.collect();
3253
+ return this.status(environment);
3254
+ }
3255
+ const changed = !existed || environment.rootPath !== checkout || environment.commandsHash !== commandsHash;
3256
+ environment.rootPath = checkout;
3257
+ environment.commandsHash = commandsHash;
3258
+ if (!commands.length) {
3259
+ environment.state = "unconfigured";
3260
+ environment.log = "Configure dependencies with git config devfast.prepare '<command>'.";
3261
+ } else if (await markerMatches(reviewPrepareMarkerPath(checkout), commandsHash)) {
3262
+ environment.state = "ready";
3263
+ environment.log = "";
3264
+ } else if (environment.state !== "failed" || changed || retryFailed) {
3265
+ environment.state = "preparing";
3266
+ environment.generation = randomUUID();
3267
+ environment.log = "Preparing pinned checkout…";
3268
+ this.save(environment);
3269
+ this.prepare(environment, commands);
3270
+ return this.status(environment);
3271
+ }
3272
+ if (changed) environment.generation = randomUUID();
3273
+ } catch (error) {
3274
+ environment.state = "failed";
3275
+ environment.rootPath = null;
3276
+ environment.log = errorMessage(error);
3277
+ }
3278
+ this.save(environment);
3279
+ return this.status(environment);
3280
+ }
3281
+ prepare(environment, commands) {
3282
+ const abort = new AbortController();
3283
+ const done = prepareReviewPinnedCheckout({
3284
+ checkoutPath: environment.rootPath,
3285
+ commit: environment.commit,
3286
+ commands,
3287
+ signal: abort.signal,
3288
+ progress: (log) => {
3289
+ environment.log = log;
3290
+ this.save(environment);
3291
+ },
3292
+ warning: (log) => {
3293
+ environment.log = log;
3294
+ }
3295
+ }).then(async (result) => {
3296
+ environment.state = result.prepared ? "ready" : "failed";
3297
+ if (!result.prepared) environment.log = await readFile(reviewPrepareLogPath(environment.rootPath), "utf8").catch(() => environment.log);
3298
+ }).catch((error) => {
3299
+ environment.state = "failed";
3300
+ environment.log = errorMessage(error);
3301
+ }).finally(() => {
3302
+ environment.generation = randomUUID();
3303
+ this.save(environment);
3304
+ this.jobs.delete(environment.id);
3305
+ });
3306
+ this.jobs.set(environment.id, {
3307
+ done,
3308
+ abort
3309
+ });
3310
+ }
3311
+ async retry(reviewId, id) {
3312
+ const environment = this.get(id);
3313
+ if (!environment || environment.reviewId !== reviewId) throw new ReviewInputError("Language environment not found.", 404);
3314
+ if (this.jobs.has(id)) return this.status(environment);
3315
+ if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
3316
+ if (environment.state === "cleanup-failed") {
3317
+ this.collect();
3318
+ return this.status(environment);
3319
+ }
3320
+ this.assertReview(reviewId);
3321
+ if (environment.rootPath) await rm(reviewPrepareMarkerPath(environment.rootPath), { force: true });
3322
+ environment.state = "pending";
3323
+ environment.generation = randomUUID();
3324
+ this.save(environment);
3325
+ return this.source(reviewId, {
3326
+ repositoryId: environment.repositoryId,
3327
+ base: environment.commit,
3328
+ head: environment.commit
3329
+ }, "head");
3330
+ }
3331
+ collect(retryId, removedReviewId) {
3332
+ const deleted = this.all().filter((environment) => (environment.reviewId === removedReviewId || !this.hasReview(environment.reviewId)) && (environment.state !== "cleanup-failed" || environment.id === retryId || environment.reviewId === removedReviewId) && this.claim(environment.reviewId));
3333
+ this.cleanup = this.cleanup.then(async () => {
3334
+ for (const environment of deleted) {
3335
+ const job = this.jobs.get(environment.id);
3336
+ job?.abort.abort();
3337
+ await job?.done;
3338
+ try {
3339
+ if (environment.rootPath && existsSync(environment.rootPath)) {
3340
+ const managed = reviewManagedCheckoutRoot(environment.repository, environment.reviewId);
3341
+ const relative = path.relative(managed, environment.rootPath);
3342
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Refusing to remove a non-managed checkout.");
3343
+ await git(environment.repository, [
3344
+ "worktree",
3345
+ "remove",
3346
+ "--force",
3347
+ environment.rootPath
3348
+ ]);
3349
+ }
3350
+ if (environment.rootPath) await removeReviewPrepareArtifacts(environment.rootPath);
3351
+ this.db.prepare("DELETE FROM pinned_environments WHERE id=?").run(environment.id);
3352
+ this.db.prepare("DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)").run(environment.reviewId, this.ownerId, environment.reviewId);
3353
+ } catch (error) {
3354
+ environment.state = "cleanup-failed";
3355
+ environment.log = errorMessage(error);
3356
+ this.save(environment);
3357
+ }
3358
+ }
3359
+ });
3360
+ }
3361
+ async idle() {
3362
+ await Promise.all(this.requests.values());
3363
+ await Promise.all([...this.jobs.values()].map((job) => job.done));
3364
+ await this.cleanup;
3365
+ }
3366
+ closing;
3367
+ close() {
3368
+ return this.closing ??= this.closeAll();
3369
+ }
3370
+ async closeAll() {
3371
+ this.closed = true;
3372
+ this.stop();
3373
+ this.stopExternal?.();
3374
+ await Promise.all(this.requests.values());
3375
+ for (const job of this.jobs.values()) job.abort.abort();
3376
+ await this.idle();
3377
+ this.db.prepare("DELETE FROM workspace_leases WHERE owner=?").run(this.ownerId);
3378
+ this.db.close();
3379
+ }
3380
+ };
3381
+ //#endregion
3382
+ //#region src/review-api/worktree-source.ts
3383
+ const { lstat: lstat$1, readFile: readFile$1, readlink, realpath: realpath$1 } = checkoutFs;
3384
+ const exec = promisify(execFile);
3385
+ const EMPTY_SOURCE = "empty-worktree-baseline";
3386
+ /** Never follow a working-copy link outside the selected checkout. */
3387
+ async function localSourcePath(root, file) {
3388
+ const canonicalRoot = await realpath$1(root);
3389
+ const candidate = await realpath$1(resolve(canonicalRoot, file));
3390
+ const child = relative(canonicalRoot, candidate);
3391
+ if (!child || isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`)) throw new ReviewInputError("Source symlink leaves the selected worktree.");
3392
+ if (!(await lstat$1(candidate)).isFile()) throw new ReviewInputError("Source is not a regular file.");
3393
+ return candidate;
3394
+ }
3395
+ /** List the live checkout's tracked files. jj tracks new files itself; Git's untracked files are left out. */
3396
+ async function workingFiles(vcs) {
3397
+ const gitDirectory = vcs.kind === "jj" ? await gitCommonDir(vcs.rootPath) : void 0;
3398
+ if (vcs.kind === "jj" && !gitDirectory) throw new ReviewInputError("Working source requires a Git-backed jj repository.");
3399
+ const { stdout } = await exec("git", [
3400
+ "-C",
3401
+ vcs.rootPath,
3402
+ ...gitDirectory ? [
3403
+ "--git-dir",
3404
+ gitDirectory,
3405
+ "--work-tree",
3406
+ vcs.rootPath
3407
+ ] : [],
3408
+ "ls-files",
3409
+ "-z",
3410
+ ...vcs.kind === "git" ? ["--cached"] : [
3411
+ "--others",
3412
+ "--exclude-standard",
3413
+ "--exclude=.jj/"
3414
+ ]
3415
+ ], { maxBuffer: 33554432 });
3416
+ const paths = [.../* @__PURE__ */ new Set([...stdout.split("\0").filter(Boolean), ...vcs.kind === "jj" ? await vcs.listTrackedFiles() : []])].sort();
3417
+ return (await Promise.all(paths.map(async (file) => {
3418
+ try {
3419
+ await lstat$1(resolve(vcs.rootPath, file));
3420
+ return file;
3421
+ } catch (error) {
3422
+ if (isMissingFileError(error)) return void 0;
3423
+ throw error;
3424
+ }
3425
+ }))).filter((file) => file !== void 0);
3426
+ }
3427
+ /**
3428
+ * Count Git's untracked, non-ignored files, which working tree reviews leave
3429
+ * out. Undefined for jj, which tracks new files itself, or when the listing
3430
+ * is too large to read.
3431
+ */
3432
+ async function untrackedFileCount(vcs) {
3433
+ if (vcs.kind !== "git") return void 0;
3434
+ try {
3435
+ const { stdout } = await exec("git", [
3436
+ "-C",
3437
+ vcs.rootPath,
3438
+ "ls-files",
3439
+ "-z",
3440
+ "--others",
3441
+ "--exclude-standard",
3442
+ "--exclude=.jj/"
3443
+ ], { maxBuffer: 33554432 });
3444
+ return stdout.split("\0").filter(Boolean).length;
3445
+ } catch {
3446
+ return;
3447
+ }
3448
+ }
3449
+ /** A refresh token, not stored source: all reads still use the checkout. */
3450
+ async function inspectWorktree(repositoryId, vcs) {
3451
+ const commit = (await vcs.resolveRevision(vcs.kind === "jj" ? "@" : "HEAD"))?.commit ?? "empty-worktree-baseline";
3452
+ const hash = createHash("sha256").update(`${repositoryId}\0${commit}`);
3453
+ for (const file of await workingFiles(vcs)) try {
3454
+ const info = await lstat$1(resolve(vcs.rootPath, file), { bigint: true });
3455
+ hash.update(`\0${file}\0${info.ino}:${info.size}:${info.mode}:${info.mtimeNs}:${info.ctimeNs}`);
3456
+ } catch (error) {
3457
+ if (!isMissingFileError(error)) throw error;
3458
+ }
3459
+ return {
3460
+ revision: hash.digest("hex"),
3461
+ commit
3462
+ };
3463
+ }
3464
+ async function readWorkingFile(root, file) {
3465
+ try {
3466
+ const candidate = resolve(root, file);
3467
+ const parent = relative(await realpath$1(root), await realpath$1(dirname(candidate)));
3468
+ if (isAbsolute(parent) || parent === ".." || parent.startsWith(`..${sep}`)) throw new ReviewInputError("Source symlink leaves the selected worktree.");
3469
+ if ((await lstat$1(candidate)).isSymbolicLink()) return readlink(candidate);
3470
+ return await readFile$1(await localSourcePath(root, file), "utf8");
3471
+ } catch (error) {
3472
+ if (isMissingFileError(error)) return null;
3473
+ throw error;
3474
+ }
3475
+ }
3476
+ //#endregion
3477
+ //#region src/review-api/local-data.ts
3478
+ const uploadSchema = z.discriminatedUnion("kind", [
3479
+ z.strictObject({
3480
+ id: z.uuid(),
3481
+ repositoryId: z.string(),
3482
+ kind: z.literal("image"),
3483
+ base64: z.string()
3484
+ }),
3485
+ z.strictObject({
3486
+ id: z.uuid(),
3487
+ repositoryId: z.string(),
3488
+ kind: z.literal("trace"),
3489
+ trace: traceSchema
3490
+ }),
3491
+ z.strictObject({
3492
+ id: z.uuid(),
3493
+ repositoryId: z.string(),
3494
+ kind: z.literal("map"),
3495
+ pins: pinsSchema,
3496
+ side: z.enum(["base", "head"]),
3497
+ model: mapInputSchema
3498
+ })
3499
+ ]);
3500
+ const unavailableCheckout = () => new ReviewInputError("The selected local checkout is unavailable.", 404);
3501
+ /** File reads cannot name the root or a directory; tree reads can. */
3502
+ function checkRelativePath(file) {
3503
+ inputError(() => checkSourcePath(file));
3504
+ if (file === "" || file.endsWith("/")) throw new ReviewInputError("Source file must be a repository-relative path.");
3505
+ }
3506
+ function sliceRange(file, source) {
3507
+ return {
3508
+ ...source,
3509
+ commit: file.commit,
3510
+ text: inputError(() => sliceSourceRange(file.text, source))
3511
+ };
3512
+ }
3513
+ /** Local source/resource boundary, including Desktop-only local language context. */
3514
+ var LocalReviewData = class {
3515
+ store;
3516
+ options;
3517
+ workspaceManager;
3518
+ get workspaces() {
3519
+ if (!this.workspaceManager) throw new ReviewInputError("Open this review in Desktop to prepare language workspaces.", 409);
3520
+ return this.workspaceManager;
3521
+ }
3522
+ currentEnvironmentIssues(snapshot) {
3523
+ if (snapshot.target?.kind !== "commits" || !snapshot.pins) return [];
3524
+ const pins = snapshot.pins;
3525
+ return this.workspaces.list(snapshot.reviewId).flatMap((environment) => {
3526
+ const side = environment.commit === pins.head ? "head" : environment.commit === pins.base ? "base" : void 0;
3527
+ return side && environment.issue ? [{
3528
+ side,
3529
+ message: environment.issue
3530
+ }] : [];
3531
+ });
3532
+ }
3533
+ async environmentIssues(snapshot, retryFailed = false) {
3534
+ const issues = [];
3535
+ if (!snapshot.pins) return issues;
3536
+ const sides = snapshot.target?.kind === "commits" && snapshot.pins.base !== snapshot.pins.head ? ["head", "base"] : ["head"];
3537
+ for (const side of sides) {
3538
+ const context = await this.languageEnvironment(snapshot, side, void 0, retryFailed);
3539
+ if (context.issue) issues.push({
3540
+ side,
3541
+ message: context.issue
3542
+ });
3543
+ }
3544
+ return issues;
3545
+ }
3546
+ untrackedCounts = /* @__PURE__ */ new Map();
3547
+ /**
3548
+ * Untracked files a live worktree review leaves out; undefined otherwise.
3549
+ * Counted once per worktree revision, never by the revision poll, so a
3550
+ * checkout with many untracked files pays for the walk only when read.
3551
+ */
3552
+ untrackedFiles(pins) {
3553
+ if (!pins.worktreeRevision) return Promise.resolve(void 0);
3554
+ const key = `${pins.repositoryId}\0${pins.worktreeRevision}`;
3555
+ let count = this.untrackedCounts.get(key);
3556
+ if (!count) {
3557
+ count = this.vcs(pins.repositoryId).then((vcs) => vcs ? untrackedFileCount(vcs) : void 0);
3558
+ this.untrackedCounts.set(key, count);
3559
+ for (const cached of this.untrackedCounts.keys()) {
3560
+ if (this.untrackedCounts.size <= 32) break;
3561
+ this.untrackedCounts.delete(cached);
3562
+ }
3563
+ }
3564
+ return count;
3565
+ }
3566
+ /** Local checkout context for live worktree targets only. */
3567
+ /** Desktop language services borrow the registered checkout, never create one. */
3568
+ async liveFile(repositoryId, file, text) {
3569
+ try {
3570
+ const rootPath = await realpath(this.store.repositoryPath(repositoryId));
3571
+ const localPath = await localSourcePath(rootPath, file);
3572
+ if (await checkoutFs.readFile(localPath, "utf8") === text) return {
3573
+ localPath,
3574
+ localRoot: rootPath
3575
+ };
3576
+ } catch {}
3577
+ }
3578
+ /** Source bytes and the language workspace have independent lifetimes. */
3579
+ async languageEnvironment(snapshot, side, commit, retryFailed = false, anchor) {
3580
+ if (!anchor && snapshot.target?.kind === "commits" && snapshot.pins) {
3581
+ const pins = await this.comparison(snapshot.pins, commit);
3582
+ const environment = await this.workspaces.source(snapshot.reviewId, pins, side, retryFailed);
3583
+ return {
3584
+ rootPath: environment.state === "preparing" || environment.state === "pending" ? null : environment.rootPath,
3585
+ identity: environment.generation,
3586
+ issue: environment.issue
3587
+ };
3588
+ }
3589
+ if (commit && !anchor) await this.comparison(await this.documentPins(snapshot), commit);
3590
+ const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
3591
+ if (!repositoryId) throw new ReviewInputError("This document has no source pins of its own.", 409);
3592
+ const unavailable = {
3593
+ rootPath: null,
3594
+ identity: `${repositoryId}:unavailable`,
3595
+ issue: "Could not access the registered language checkout. Check the repository path and permissions, then retry."
3596
+ };
3597
+ let rootPath;
3598
+ try {
3599
+ rootPath = await realpath(this.store.repositoryPath(repositoryId));
3600
+ } catch (error) {
3601
+ if (error instanceof ReviewInputError && error.status !== 404) throw error;
3602
+ return unavailable;
3603
+ }
3604
+ if (!rootPath || !await this.vcs(repositoryId)) return unavailable;
3605
+ const info = await stat(rootPath, { bigint: true }).catch(() => null);
3606
+ return info ? {
3607
+ rootPath,
3608
+ identity: `${repositoryId}:${rootPath}:${info.dev}:${info.ino}:${info.birthtimeNs}`
3609
+ } : unavailable;
3610
+ }
3611
+ /** Resolve source coordinates after selecting a local or imported snapshot.
3612
+ * `anchor` reads at a reference's own pins instead of the document's;
3613
+ * `commit` narrows the document comparison to one of its commits. */
3614
+ async resolveSource(snapshot, commit, anchor) {
3615
+ return {
3616
+ snapshot,
3617
+ pins: anchor ? await this.anchorSourcePins(anchor) : await this.comparison(await this.documentPins(snapshot), commit)
3618
+ };
3619
+ }
3620
+ /** Where an Ask agent reads source: the Review's own head checkout, or the
3621
+ * registered checkout for a live worktree target, as the navigator does. */
3622
+ async agentCheckout(snapshot) {
3623
+ const { pins } = await this.resolveSource(snapshot);
3624
+ const rootPath = pins.worktreeRevision ? await realpath(this.store.repositoryPath(pins.repositoryId)) : (await this.workspaces.source(snapshot.reviewId, pins, "head")).rootPath;
3625
+ if (!rootPath) throw new ReviewInputError("The head checkout for this review is not ready yet.", 409);
3626
+ return {
3627
+ rootPath,
3628
+ head: pins.head,
3629
+ live: !!pins.worktreeRevision
3630
+ };
3631
+ }
3632
+ /** Resolve a native workspace without replacing the selected source with today's HEAD. */
3633
+ async navigatorWorkspace(snapshot, source = {}) {
3634
+ const { pins } = await this.resolveSource(snapshot, source.commit, source.anchor);
3635
+ const repository = this.store.repositoryPath(pins.repositoryId);
3636
+ const side = source.side ?? "head";
3637
+ const live = !!pins.worktreeRevision && (side === "head" || source.empty && pins[side] === "empty-worktree-baseline");
3638
+ const checkoutSide = pins[side] === "empty-worktree-baseline" && source.empty ? "head" : side;
3639
+ const ref = pins[checkoutSide];
3640
+ if (source.file) checkRelativePath(source.file);
3641
+ const rootPath = live ? await realpath(repository) : (await this.workspaces.source(snapshot.reviewId, pins, checkoutSide)).rootPath;
3642
+ const context = await resolveRepoContext(repository);
3643
+ if (!rootPath || !context) throw new ReviewInputError("Could not open the selected source checkout.", 409);
3644
+ if (!live) {
3645
+ const { stdout } = await promisify(execFile)("git", [
3646
+ "-C",
3647
+ rootPath,
3648
+ "status",
3649
+ "--porcelain",
3650
+ "--untracked-files=no"
3651
+ ]);
3652
+ if (stdout.trim()) throw new ReviewInputError("The pinned checkout has local changes, possibly from devfast.prepare. Restore those files before browsing this pinned revision.", 409);
3653
+ }
3654
+ const name = context.githubSlug?.split("/").at(-1) ?? path.basename(path.dirname(context.commonDir));
3655
+ const workspaceDirectory = path.join(reviewManagedCheckoutRoot(context.commonDir, snapshot.reviewId), "navigator", "workspaces", live ? "worktree" : ref);
3656
+ const workspacePath = path.join(workspaceDirectory, `${name}.code-workspace`);
3657
+ const current = await readWorkspace(workspacePath);
3658
+ if (current !== null) {
3659
+ const previous = current ?? await readWorkspace(path.join(workspaceDirectory, `${path.basename(repository)}.code-workspace`));
3660
+ const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
3661
+ const workspace = previous ?? {
3662
+ folders: [],
3663
+ settings: { "files.readonlyInclude": { "**/*": true } }
3664
+ };
3665
+ const next = {
3666
+ ...workspace,
3667
+ folders: [{
3668
+ path: rootPath,
3669
+ name
3670
+ }, ...workspace.folders.slice(1)],
3671
+ settings: {
3672
+ ...workspace.settings,
3673
+ "window.title": title
3674
+ }
3675
+ };
3676
+ if (JSON.stringify(next) !== JSON.stringify(current)) await writePrivateJsonAtomic(workspacePath, next);
3677
+ }
3678
+ let filePath;
3679
+ if (source.file) {
3680
+ if (source.empty) {
3681
+ filePath = path.join(path.dirname(workspacePath), "empty", path.basename(source.file));
3682
+ await mkdir(path.dirname(filePath), { recursive: true });
3683
+ await writeFile(filePath, "", { mode: 384 });
3684
+ } else try {
3685
+ filePath = await localSourcePath(rootPath, source.file);
3686
+ } catch (error) {
3687
+ if (isMissingFileError(error)) throw new ReviewInputError("File is unavailable at the selected revision.", 404);
3688
+ throw error;
3689
+ }
3690
+ }
3691
+ return {
3692
+ workspacePath,
3693
+ filePath
3694
+ };
3695
+ }
3696
+ /** A document read that needs default pins; 409 when the document has none. */
3697
+ async documentPins(snapshot) {
3698
+ const pins = await this.sourcePins(snapshot);
3699
+ if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
3700
+ return pins;
3701
+ }
3702
+ /** Pins a reference names itself, checked against the registered checkout. */
3703
+ async anchorSourcePins(anchor) {
3704
+ if (!existsSync(this.store.repositoryPath(anchor.repositoryId))) throw unavailableCheckout();
3705
+ return anchorPins({ pins: anchor }, void 0);
3706
+ }
3707
+ /** References whose own repository is unregistered or gone from disk. */
3708
+ async unavailableAnchors(snapshot) {
3709
+ const missing = /* @__PURE__ */ new Set();
3710
+ for (const pins of explicitPins(sourceReferences(snapshot.document, { tolerant: true }))) try {
3711
+ await this.anchorSourcePins(pins);
3712
+ } catch (error) {
3713
+ if (!(error instanceof ReviewInputError)) throw error;
3714
+ missing.add(pins.repositoryId);
3715
+ }
3716
+ if (!missing.size) return [];
3717
+ return sourceReferences(snapshot.document, { tolerant: true }).filter((ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId)).map((ref) => ref.id);
3718
+ }
3719
+ trackedFiles = /* @__PURE__ */ new Map();
3720
+ repositories = /* @__PURE__ */ new Map();
3721
+ readers = /* @__PURE__ */ new Map();
3722
+ commitRanges = /* @__PURE__ */ new Map();
3723
+ constructor(store, options = {}) {
3724
+ this.store = store;
3725
+ this.options = options;
3726
+ if (options.manageWorkspaces !== false) this.workspaceManager = new ReviewWorkspaces(options.workspaceDatabase ?? ":memory:", store);
3727
+ }
3728
+ async *structuralChanges({ reviewId, pins, signal, file }) {
3729
+ if (file !== void 0) checkRelativePath(file);
3730
+ if (pins.worktreeRevision) {
3731
+ const { rootPath, baseRef } = await this.worktreeInput(pins);
3732
+ yield* this.structuralComparisons.stream({
3733
+ repositoryPath: rootPath,
3734
+ comparison: {
3735
+ kind: "worktree",
3736
+ base: baseRef ?? "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
3737
+ revision: pins.worktreeRevision
3738
+ },
3739
+ paths: file === void 0 ? void 0 : [file],
3740
+ signal
3741
+ });
3742
+ return;
3743
+ }
3744
+ await this.workspaceManager?.released(reviewId);
3745
+ const rootPath = await ensureReviewPinnedCheckout({
3746
+ rootPath: this.store.repositoryPath(pins.repositoryId),
3747
+ ref: pins.head,
3748
+ reviewUuid: reviewId
3749
+ });
3750
+ if (!rootPath) throw new ReviewInputError("Cannot prepare the pinned repository for structural diffing.");
3751
+ yield* this.structuralComparisons.stream({
3752
+ repositoryPath: rootPath,
3753
+ comparison: {
3754
+ kind: "trees",
3755
+ base: pins.base,
3756
+ head: pins.head
3757
+ },
3758
+ paths: file === void 0 ? void 0 : [file],
3759
+ signal
3760
+ });
3761
+ }
3762
+ structuralComparisons = new StructuralComparisons();
3763
+ closed = false;
3764
+ worktrees = /* @__PURE__ */ new Map();
3765
+ forgetWorktree(repositoryId) {
3766
+ const entry = this.worktrees.get(repositoryId);
3767
+ if (entry) for (const watcher of entry.watchers) watcher.close();
3768
+ this.worktrees.delete(repositoryId);
3769
+ }
3770
+ async worktreeState(repositoryId, vcs) {
3771
+ let entry = this.worktrees.get(repositoryId);
3772
+ if (!entry) {
3773
+ entry = {
3774
+ epoch: 0,
3775
+ inspectedEpoch: -1,
3776
+ watchers: [],
3777
+ healthy: true,
3778
+ forks: /* @__PURE__ */ new Map()
3779
+ };
3780
+ this.worktrees.set(repositoryId, entry);
3781
+ const state = entry;
3782
+ const roots = /* @__PURE__ */ new Set([vcs.rootPath]);
3783
+ const common = await gitCommonDir(vcs.rootPath);
3784
+ if (common) roots.add(common);
3785
+ for (const root of roots) try {
3786
+ const watcher = (this.options.watch ?? watch)(root, { recursive: true }, () => {
3787
+ state.epoch++;
3788
+ });
3789
+ watcher.on("error", () => {
3790
+ state.epoch++;
3791
+ state.inspectedEpoch = -1;
3792
+ state.healthy = false;
3793
+ watcher.close();
3794
+ });
3795
+ watcher.unref();
3796
+ state.watchers.push(watcher);
3797
+ } catch {
3798
+ state.healthy = false;
3799
+ }
3800
+ }
3801
+ if (entry.inspection && entry.inspectedEpoch === entry.epoch && entry.healthy && entry.watchers.length) return {
3802
+ ...entry.inspection,
3803
+ forks: entry.forks
3804
+ };
3805
+ const epoch = entry.epoch;
3806
+ const inspected = await inspectWorktree(repositoryId, vcs);
3807
+ entry.inspection = inspected;
3808
+ entry.inspectedEpoch = epoch;
3809
+ entry.forks = /* @__PURE__ */ new Map();
3810
+ return {
3811
+ ...inspected,
3812
+ forks: entry.forks
3813
+ };
3814
+ }
3815
+ async close() {
3816
+ this.coverageAbort.abort();
3817
+ clearTimeout(this.coverageNotification);
3818
+ this.coverageListeners.clear();
3819
+ this.coverageCache.clear();
3820
+ this.structuralComparisons.close();
3821
+ this.closed = true;
3822
+ await this.workspaceManager?.close();
3823
+ for (const entry of this.worktrees.values()) for (const watcher of entry.watchers) watcher.close();
3824
+ this.worktrees.clear();
3825
+ const readers = [...this.readers.values()];
3826
+ this.readers.clear();
3827
+ await Promise.all(readers.map((reader) => reader.close()));
3828
+ }
3829
+ /** Detected once; dropped when the root vanishes or detection found nothing. */
3830
+ vcs(repositoryId) {
3831
+ const cached = this.repositories.get(repositoryId);
3832
+ if (cached && (!cached.vcs || existsSync(cached.vcs.rootPath))) return cached.detection;
3833
+ this.closeReader(repositoryId);
3834
+ this.forgetWorktree(repositoryId);
3835
+ const rootPath = this.store.repositoryPath(repositoryId);
3836
+ const forget = () => {
3837
+ this.repositories.delete(repositoryId);
3838
+ this.closeReader(repositoryId);
3839
+ };
3840
+ const entry = { detection: detectLocalVcs(rootPath).then((vcs) => {
3841
+ if (vcs) entry.vcs = vcs;
3842
+ else forget();
3843
+ return vcs;
3844
+ }, (cause) => {
3845
+ forget();
3846
+ throw cause;
3847
+ }) };
3848
+ this.repositories.set(repositoryId, entry);
3849
+ return entry.detection;
3850
+ }
3851
+ /** None once closed: a read suspended across close() gets its own process. */
3852
+ reader(repositoryId, vcs) {
3853
+ if (this.closed) return void 0;
3854
+ const existing = this.readers.get(repositoryId);
3855
+ if (existing) return existing;
3856
+ const reader = createBlobBatchReader({
3857
+ rootPath: vcs.rootPath,
3858
+ kind: vcs.kind,
3859
+ idleTimeoutMs: this.options.blobReaderIdleTimeoutMs
3860
+ });
3861
+ this.readers.set(repositoryId, reader);
3862
+ return reader;
3863
+ }
3864
+ closeReader(repositoryId) {
3865
+ const reader = this.readers.get(repositoryId);
3866
+ if (!reader) return;
3867
+ this.readers.delete(repositoryId);
3868
+ return reader.close();
3869
+ }
3870
+ async forgetRepository(repositoryId) {
3871
+ await this.closeReader(repositoryId);
3872
+ this.forgetWorktree(repositoryId);
3873
+ this.repositories.delete(repositoryId);
3874
+ for (const key of this.trackedFiles.keys()) if (key.startsWith(repositoryId + "\0")) this.trackedFiles.delete(key);
3875
+ for (const key of this.commitRanges.keys()) if (key.startsWith(repositoryId + ":")) this.commitRanges.delete(key);
3876
+ }
3877
+ async vcsTarget(repositoryId) {
3878
+ const vcs = await this.vcs(repositoryId);
3879
+ if (vcs) return {
3880
+ rootPath: vcs.rootPath,
3881
+ kind: vcs.kind
3882
+ };
3883
+ const rootPath = this.store.repositoryPath(repositoryId);
3884
+ if (!existsSync(rootPath)) throw unavailableCheckout();
3885
+ return { rootPath };
3886
+ }
3887
+ async register(root) {
3888
+ const resolved = await realpath(root).catch(() => {
3889
+ throw new ReviewInputError("Repository path does not exist or is not readable.");
3890
+ });
3891
+ const vcs = await detectLocalVcs(resolved).catch((cause) => {
3892
+ if (cause instanceof LocalVcsToolsMissingError) throw new ReviewInputError(cause.message);
3893
+ throw cause;
3894
+ });
3895
+ if (!vcs) throw new ReviewInputError("Choose a Git or jj repository.");
3896
+ const repository = this.store.registerRepository(await realpath(vcs.rootPath));
3897
+ await this.closeReader(repository.id);
3898
+ this.repositories.delete(repository.id);
3899
+ return repository;
3900
+ }
3901
+ async projectSource(snapshot, pins) {
3902
+ const projected = {
3903
+ ...snapshot,
3904
+ pins,
3905
+ staleSources: []
3906
+ };
3907
+ delete projected.sourceUnavailable;
3908
+ for (const reference of sourceReferences(snapshot.document, { tolerant: true })) try {
3909
+ await this.quote(anchorPins(reference.source, pins), reference.source);
3910
+ } catch (error) {
3911
+ if (!(error instanceof ReviewInputError)) throw error;
3912
+ projected.staleSources.push(reference.id);
3913
+ }
3914
+ return projected;
3915
+ }
3916
+ /** Throws 404 when the checkout behind the snapshot is gone. Undefined
3917
+ * for a document without default pins. */
3918
+ async sourcePins(snapshot) {
3919
+ if (snapshot.target?.kind === "worktree") return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
3920
+ if (!snapshot.pins) return void 0;
3921
+ if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId))) throw unavailableCheckout();
3922
+ return snapshot.pins;
3923
+ }
3924
+ /** Capture a label only when its ref still resolves to these exact pins. */
3925
+ async headBranch(pins, headRef) {
3926
+ const vcs = await this.vcs(pins.repositoryId);
3927
+ if (!vcs || vcs.kind !== "git") return void 0;
3928
+ try {
3929
+ const { stdout } = await promisify(execFile)("git", [
3930
+ "-C",
3931
+ vcs.rootPath,
3932
+ "rev-parse",
3933
+ "--symbolic-full-name",
3934
+ "--verify",
3935
+ "--end-of-options",
3936
+ !headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : headRef
3937
+ ]);
3938
+ const ref = stdout.trim();
3939
+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) return void 0;
3940
+ if ((await vcs.resolveRevision(ref))?.commit !== pins.head) return void 0;
3941
+ return ref.replace(/^refs\/(heads|remotes)\//, "");
3942
+ } catch {
3943
+ return;
3944
+ }
3945
+ }
3946
+ /** `pinned` refreshes a stored target: a worktree base ref resolves to its
3947
+ * current fork point, and keeps the pinned one if the ref is gone. */
3948
+ async resolveTarget(target, pinned) {
3949
+ const vcs = await this.vcs(target.repositoryId);
3950
+ if (!vcs) throw unavailableCheckout();
3951
+ if (target.kind === "commits") {
3952
+ const head = await vcs.resolveRevision(target.head);
3953
+ if (!head) throw new ReviewInputError("Head revision does not exist.");
3954
+ const base = target.base === void 0 ? head : await vcs.resolveRevision(target.base);
3955
+ if (!base) throw new ReviewInputError("Base revision does not exist.");
3956
+ const resolved = {
3957
+ ...target,
3958
+ head: head.commit
3959
+ };
3960
+ if (target.base !== void 0) resolved.base = base.commit;
3961
+ return {
3962
+ target: resolved,
3963
+ pins: {
3964
+ repositoryId: target.repositoryId,
3965
+ base: base.commit,
3966
+ head: head.commit
3967
+ }
3968
+ };
3969
+ }
3970
+ const { revision, commit, forks } = await this.worktreeState(target.repositoryId, vcs);
3971
+ const resolved = { ...target };
3972
+ const fork = () => {
3973
+ const key = `${target.base ?? ""}\0${commit}`;
3974
+ let found = forks.get(key);
3975
+ if (!found) {
3976
+ found = this.worktreeBase(vcs, target.base, commit);
3977
+ forks.set(key, found);
3978
+ found.catch((error) => {
3979
+ if (!(error instanceof ReviewInputError)) forks.delete(key);
3980
+ });
3981
+ }
3982
+ return found;
3983
+ };
3984
+ const base = pinned && target.base === void 0 ? pinned.base === "empty-worktree-baseline" ? EMPTY_SOURCE : commit : commit === "empty-worktree-baseline" && target.base === void 0 ? EMPTY_SOURCE : await fork().then((found) => {
3985
+ resolved.base = found.ref;
3986
+ return found.commit;
3987
+ }, (error) => {
3988
+ if (pinned && error instanceof ReviewInputError) return pinned.base;
3989
+ throw error;
3990
+ });
3991
+ return {
3992
+ target: resolved,
3993
+ pins: {
3994
+ repositoryId: target.repositoryId,
3995
+ base,
3996
+ head: commit,
3997
+ worktreeRevision: revision
3998
+ }
3999
+ };
4000
+ }
4001
+ /** The fork point from `ref`, by default the repository's default branch. */
4002
+ async worktreeBase(vcs, ref, head) {
4003
+ const branch = ref ?? (await vcs.defaultBranch())?.ref;
4004
+ if (!branch) throw new ReviewInputError("No default branch found (tried origin/HEAD, origin/main, origin/master, main and master). Supply target.base.");
4005
+ if (!await vcs.resolveRevision(branch)) throw new ReviewInputError("Base revision does not exist.");
4006
+ const fork = head === "empty-worktree-baseline" ? null : await vcs.mergeBase(branch, head);
4007
+ if (!fork) throw new ReviewInputError(`${branch} shares no history with the checkout's HEAD.`);
4008
+ return {
4009
+ ref: branch,
4010
+ commit: fork.commit
4011
+ };
4012
+ }
4013
+ /** The PR's current comparison, fetched into a registered checkout of its repository. */
4014
+ async resolvePullRequest(url, repository) {
4015
+ const deps = this.options.pullRequests ?? defaultPullRequestDeps;
4016
+ const { host, slug, number } = pullRequestAddress(url);
4017
+ const checkout = await this.pullRequestCheckout(host, slug, repository, deps);
4018
+ const pullRequest = await readPullRequest(url, deps);
4019
+ const { head, base } = await fetchPullRequest({
4020
+ ...checkout,
4021
+ pullRequest
4022
+ }, deps);
4023
+ const repositoryId = checkout.repositoryId;
4024
+ return {
4025
+ target: {
4026
+ kind: "commits",
4027
+ repositoryId,
4028
+ head,
4029
+ base
4030
+ },
4031
+ pins: {
4032
+ repositoryId,
4033
+ base,
4034
+ head
4035
+ },
4036
+ title: pullRequest.title.trim() || `PR #${number}`
4037
+ };
4038
+ }
4039
+ /** A registered checkout with a remote for host/owner/repo, and that remote. */
4040
+ async pullRequestCheckout(host, slug, repository, deps) {
4041
+ const registered = this.store.repositories();
4042
+ const candidates = repository.id ? registered.filter((entry) => entry.id === repository.id) : [...registered.filter((entry) => entry.id === repository.preferred), ...registered.filter((entry) => entry.id !== repository.preferred)];
4043
+ if (repository.id && candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
4044
+ for (const { id } of candidates) {
4045
+ if (!existsSync(this.store.repositoryPath(id))) continue;
4046
+ const vcs = await this.vcs(id);
4047
+ const gitDir = vcs && await gitCommonDir(vcs.rootPath);
4048
+ if (!vcs || !gitDir) continue;
4049
+ const remote = (await githubRemotes(gitDir, deps)).find((entry) => entry.host === host && entry.slug.toLowerCase() === slug.toLowerCase());
4050
+ if (remote) return {
4051
+ repositoryId: id,
4052
+ rootPath: vcs.rootPath,
4053
+ gitDir,
4054
+ kind: vcs.kind,
4055
+ remote: remote.name
4056
+ };
4057
+ }
4058
+ const name = host === "github.com" ? slug : `${host}/${slug}`;
4059
+ throw new ReviewInputError(repository.id ? `That checkout has no GitHub remote for ${name}. Add one, or omit repositoryId.` : `No registered checkout has a GitHub remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`, 404);
4060
+ }
4061
+ async resolvePins(repositoryId, base, head) {
4062
+ const vcs = await this.vcs(repositoryId);
4063
+ const [left, right] = vcs ? await Promise.all([vcs.resolveRevision(base), vcs.resolveRevision(head)]) : [null, null];
4064
+ if (!left || !right) throw new ReviewInputError(`Base or head revision does not exist in the local checkout (${!left ? `base: ${base}` : `head: ${head}`}). Fetch the requested commits before authoring; in CI, configure checkout depth to include both revisions.`);
4065
+ return {
4066
+ repositoryId,
4067
+ base: left.commit,
4068
+ head: right.commit
4069
+ };
4070
+ }
4071
+ /** Files each side's commit changed from one set of pins to another, under
4072
+ * both names for a rename. A worktree has no commit to diff from. */
4073
+ async filesChangedBetween(from, to) {
4074
+ if (from.repositoryId !== to.repositoryId || from.worktreeRevision || to.worktreeRevision) return void 0;
4075
+ const target = await this.vcsTarget(to.repositoryId);
4076
+ const changed = async (side) => {
4077
+ if (from[side] === to[side]) return /* @__PURE__ */ new Set();
4078
+ const files = await diffFileSummariesTrees({
4079
+ ...target,
4080
+ baseRef: from[side],
4081
+ headRef: to[side]
4082
+ });
4083
+ return new Set(files.flatMap((file) => file.previousPath ? [file.previousPath, file.path] : [file.path]));
4084
+ };
4085
+ const [base, head] = await Promise.all([changed("base"), changed("head")]);
4086
+ return {
4087
+ base,
4088
+ head
4089
+ };
4090
+ }
4091
+ async validatePins(pins) {
4092
+ if (pins.worktreeRevision) {
4093
+ if (!await this.vcs(pins.repositoryId)) throw unavailableCheckout();
4094
+ return;
4095
+ }
4096
+ const resolved = await this.resolvePins(pins.repositoryId, pins.base, pins.head);
4097
+ if (resolved.base !== pins.base || resolved.head !== pins.head) throw new ReviewInputError("Use resolved commit IDs, not moving branch names.");
4098
+ }
4099
+ async file(pins, side, file, allowBinary = false) {
4100
+ checkRelativePath(file);
4101
+ const commit = pins[side];
4102
+ const vcs = await this.vcs(pins.repositoryId);
4103
+ const text = pins.worktreeRevision && side === "head" ? vcs ? await readWorkingFile(vcs.rootPath, file) : null : commit === "empty-worktree-baseline" ? null : vcs ? await readFileAtCommit({
4104
+ rootPath: vcs.rootPath,
4105
+ kind: vcs.kind,
4106
+ commit,
4107
+ relativePath: file,
4108
+ reader: this.reader(pins.repositoryId, vcs)
4109
+ }) : null;
4110
+ if (text === null) throw new ReviewInputError("File is unavailable at the pinned commit.", 404);
4111
+ if (!allowBinary && text.includes("\0")) throw new ReviewInputError("Binary files cannot be used as code references.");
4112
+ return {
4113
+ file,
4114
+ side,
4115
+ commit,
4116
+ text
4117
+ };
4118
+ }
4119
+ async tree(pins, side, directory) {
4120
+ inputError(() => checkSourcePath(directory));
4121
+ const prefix = directory ? directory.replace(/\/$/, "") + "/" : "";
4122
+ const entries = /* @__PURE__ */ new Map();
4123
+ const vcs = pins.worktreeRevision ? await this.vcs(pins.repositoryId) : void 0;
4124
+ if (pins.worktreeRevision && !vcs) throw unavailableCheckout();
4125
+ const files = vcs && side === "head" ? await workingFiles(vcs) : pins[side] === "empty-worktree-baseline" ? [] : await this.trackedFilesAt(pins.repositoryId, pins[side]);
4126
+ for (const file of files) {
4127
+ if (!file.startsWith(prefix)) continue;
4128
+ const relative = file.slice(prefix.length);
4129
+ const name = relative.split("/", 1)[0];
4130
+ entries.set(name, {
4131
+ path: prefix + name,
4132
+ kind: relative.includes("/") ? "directory" : "file"
4133
+ });
4134
+ }
4135
+ if (directory && entries.size === 0) throw new ReviewInputError("Directory is unavailable at the pinned commit.", 404);
4136
+ return [...entries.values()];
4137
+ }
4138
+ trackedFilesAt(repositoryId, ref) {
4139
+ const key = `${repositoryId}\0${ref}`;
4140
+ let files = this.trackedFiles.get(key);
4141
+ if (!files) {
4142
+ files = this.vcs(repositoryId).then((vcs) => {
4143
+ if (!vcs) {
4144
+ this.trackedFiles.delete(key);
4145
+ return [];
4146
+ }
4147
+ return listTrackedFilesAtCommit({
4148
+ rootPath: vcs.rootPath,
4149
+ kind: vcs.kind,
4150
+ commit: ref
4151
+ });
4152
+ });
4153
+ this.trackedFiles.set(key, files);
4154
+ files.catch(() => this.trackedFiles.delete(key));
4155
+ }
4156
+ return files;
4157
+ }
4158
+ async validateSources(pins, sources) {
4159
+ const files = /* @__PURE__ */ new Map();
4160
+ for (const input of sources) {
4161
+ const source = fileLineRangeSchema.parse(input);
4162
+ const key = JSON.stringify([
4163
+ pins[source.side],
4164
+ pins.worktreeRevision ? source.side : null,
4165
+ source.file
4166
+ ]);
4167
+ const ranges = files.get(key);
4168
+ if (ranges) ranges.push(source);
4169
+ else files.set(key, [source]);
4170
+ }
4171
+ for (const ranges of files.values()) {
4172
+ const first = ranges[0];
4173
+ const file = await this.file(pins, first.side, first.file);
4174
+ for (const source of ranges) sliceRange(file, source);
4175
+ }
4176
+ }
4177
+ async quote(pins, source) {
4178
+ source = fileLineRangeSchema.parse(source);
4179
+ return sliceRange(await this.file(pins, source.side, source.file), source);
4180
+ }
4181
+ /** Every source reference must exist at the pins; only code peeks must also
4182
+ * show something. */
4183
+ async validateSource(pins, source, options) {
4184
+ const quote = await this.quote(pins, source);
4185
+ if (options.peek) inputError(() => requireVisibleSource(quote.text, source));
4186
+ }
4187
+ coverageCache = /* @__PURE__ */ new Map();
4188
+ coverageAbort = new AbortController();
4189
+ coverageListeners = /* @__PURE__ */ new Set();
4190
+ coverageRevision = 0;
4191
+ subscribeCoverage(listener) {
4192
+ this.coverageListeners.add(listener);
4193
+ return () => {
4194
+ this.coverageListeners.delete(listener);
4195
+ };
4196
+ }
4197
+ /** Start shared coverage work without occupying an HTTP request until it completes. */
4198
+ coveragePending(reviewId, pins, mode) {
4199
+ const key = JSON.stringify([pins, mode]);
4200
+ const existing = this.coverageCache.get(key);
4201
+ if (existing?.state === "error") {
4202
+ this.coverageCache.delete(key);
4203
+ throw existing.error;
4204
+ }
4205
+ this.coverage(reviewId, pins, mode).catch(() => {});
4206
+ return this.coverageCache.get(key).state === "pending";
4207
+ }
4208
+ coverageSnapshot(reviewId, pins, mode) {
4209
+ return {
4210
+ pending: this.coveragePending(reviewId, pins, mode),
4211
+ comparison: this.coverageCache.get(JSON.stringify([pins, mode])).partial ?? {
4212
+ files: [],
4213
+ fileSources: /* @__PURE__ */ new Map(),
4214
+ alignments: /* @__PURE__ */ new Map()
4215
+ }
4216
+ };
4217
+ }
4218
+ coverageNotification;
4219
+ notifyCoverage() {
4220
+ if (this.closed || this.coverageNotification) return;
4221
+ this.coverageNotification = setTimeout(() => {
4222
+ this.coverageNotification = void 0;
4223
+ this.coverageRevision++;
4224
+ for (const listener of this.coverageListeners) listener();
4225
+ }, 50);
4226
+ }
4227
+ coverage(reviewId, pins, mode) {
4228
+ const key = JSON.stringify([pins, mode]);
4229
+ let entry = this.coverageCache.get(key);
4230
+ if (!entry || entry.state === "error") {
4231
+ const promise = comparisonCoverage(this, reviewId, pins, mode, this.coverageAbort.signal, (partial) => {
4232
+ const current = this.coverageCache.get(key);
4233
+ if (current) current.partial = partial;
4234
+ this.notifyCoverage();
4235
+ });
4236
+ entry = {
4237
+ promise,
4238
+ state: "pending"
4239
+ };
4240
+ this.coverageCache.set(key, entry);
4241
+ const current = entry;
4242
+ const settled = (state, error) => {
4243
+ current.state = state;
4244
+ current.error = error;
4245
+ this.notifyCoverage();
4246
+ for (const [cachedKey, cached] of this.coverageCache) {
4247
+ if (this.coverageCache.size <= 32) break;
4248
+ if (cached.state !== "pending" && cachedKey !== key) this.coverageCache.delete(cachedKey);
4249
+ }
4250
+ };
4251
+ promise.then((value) => {
4252
+ current.partial = value;
4253
+ if (!this.closed) this.store.setDiffStats(pins, {
4254
+ fileCount: value.files.length,
4255
+ additions: value.files.reduce((sum, file) => sum + structuralChangeCounts(file.changed).added, 0),
4256
+ deletions: value.files.reduce((sum, file) => sum + structuralChangeCounts(file.changed).removed, 0)
4257
+ }, mode);
4258
+ settled("ready");
4259
+ }, (error) => settled("error", error));
4260
+ }
4261
+ return entry.promise;
4262
+ }
4263
+ async changes(pins, file) {
4264
+ if (file === void 0) return this.summaries(pins);
4265
+ checkRelativePath(file);
4266
+ return this.rawPatch(pins, { paths: [file] });
4267
+ }
4268
+ async summaries(pins) {
4269
+ if (pins.worktreeRevision) return diffFileSummariesWorkingTree(await this.worktreeInput(pins));
4270
+ return diffFileSummariesTrees({
4271
+ ...await this.vcsTarget(pins.repositoryId),
4272
+ baseRef: pins.base,
4273
+ headRef: pins.head
4274
+ });
4275
+ }
4276
+ /** Raw Git patch text for exact filenames, or every change. */
4277
+ async rawPatch(pins, options) {
4278
+ if (pins.worktreeRevision) return diffWorkingTree({
4279
+ ...await this.worktreeInput(pins),
4280
+ ...options
4281
+ });
4282
+ return diffTrees({
4283
+ ...await this.vcsTarget(pins.repositoryId),
4284
+ baseRef: pins.base,
4285
+ headRef: pins.head,
4286
+ ...options,
4287
+ literalPaths: options.paths !== void 0
4288
+ });
4289
+ }
4290
+ async worktreeInput(pins) {
4291
+ const vcs = await this.vcs(pins.repositoryId);
4292
+ if (!vcs) throw unavailableCheckout();
4293
+ return {
4294
+ rootPath: vcs.rootPath,
4295
+ kind: vcs.kind,
4296
+ baseRef: pins.base === "empty-worktree-baseline" ? void 0 : pins.base,
4297
+ headRef: pins.head === "empty-worktree-baseline" ? void 0 : pins.head
4298
+ };
4299
+ }
4300
+ commits(pins) {
4301
+ if (pins.base === pins.head || pins.base === "empty-worktree-baseline" || pins.head === "empty-worktree-baseline") return Promise.resolve([]);
4302
+ const key = `${pins.repositoryId}:${pins.base}:${pins.head}`;
4303
+ const cached = this.commitRanges.get(key);
4304
+ if (cached) return cached;
4305
+ const pending = this.readCommitRange(pins).catch((cause) => {
4306
+ this.commitRanges.delete(key);
4307
+ throw cause;
4308
+ });
4309
+ this.commitRanges.set(key, pending);
4310
+ return pending;
4311
+ }
4312
+ async readCommitRange(pins) {
4313
+ return listCommitRange({
4314
+ ...await this.vcsTarget(pins.repositoryId),
4315
+ baseRef: pins.base,
4316
+ headRef: pins.head
4317
+ });
4318
+ }
4319
+ async comparison(pins, commit) {
4320
+ if (!commit) return pins;
4321
+ const selected = (await this.commits(pins)).find((item) => item.commit === commit);
4322
+ if (!selected) throw new ReviewInputError("The selected commit is not part of this review version.", 404);
4323
+ return {
4324
+ repositoryId: pins.repositoryId,
4325
+ base: selected.parentCommit,
4326
+ head: selected.commit
4327
+ };
4328
+ }
4329
+ async map(documentPins, resourceId) {
4330
+ await this.validateResource(documentPins, {
4331
+ type: "software_map",
4332
+ mapVersionId: resourceId
4333
+ });
4334
+ const resource = this.store.resource(resourceId);
4335
+ const saved = JSON.parse(Buffer.from(resource.data).toString());
4336
+ const pins = documentPins ?? {
4337
+ repositoryId: resource.repositoryId,
4338
+ base: saved.commit,
4339
+ head: saved.commit
4340
+ };
4341
+ const target = await this.vcsTarget(pins.repositoryId);
4342
+ const resolved = await resolveSoftwareMapDiffCounts({
4343
+ patch: pins.worktreeRevision ? await diffWorkingTree({
4344
+ ...target,
4345
+ kind: target.kind ?? "git",
4346
+ baseRef: pins.base === "empty-worktree-baseline" ? void 0 : pins.base,
4347
+ headRef: pins.head === "empty-worktree-baseline" ? void 0 : pins.head
4348
+ }) : void 0,
4349
+ sourceRootPath: target.rootPath,
4350
+ sourceVcsKind: target.kind,
4351
+ baseRef: pins.base,
4352
+ headRef: pins.head,
4353
+ side: saved.side,
4354
+ codeElements: saved.elements.filter((element) => element.type === "codeElement"),
4355
+ coverageClaims: saved.elements.flatMap((element) => element.coverage ? [{
4356
+ path: element.path,
4357
+ ...element.coverage
4358
+ }] : [])
4359
+ });
4360
+ return {
4361
+ ...saved,
4362
+ countsByElementPath: resolved.countsByElementPath,
4363
+ unmappedByElementPath: resolved.unmappedByElementPath
4364
+ };
4365
+ }
4366
+ async upload(value) {
4367
+ const input = uploadSchema.parse(value);
4368
+ this.store.repositoryPath(input.repositoryId);
4369
+ let mimeType = "application/json", data;
4370
+ switch (input.kind) {
4371
+ case "image":
4372
+ data = await decodeImage(Buffer.from(input.base64, "base64"));
4373
+ mimeType = "image/png";
4374
+ break;
4375
+ case "trace":
4376
+ if (new Set(input.trace.events.map((event) => event.id)).size !== input.trace.events.length) throw new ReviewInputError("Trace event IDs must be unique.");
4377
+ data = Buffer.from(JSON.stringify({
4378
+ ...input.trace,
4379
+ provenance: "client_supplied"
4380
+ }));
4381
+ break;
4382
+ case "map": {
4383
+ if (input.pins.repositoryId !== input.repositoryId) throw new ReviewInputError("Map belongs to a different repository.");
4384
+ await this.validatePins(input.pins);
4385
+ let model;
4386
+ try {
4387
+ model = defineSoftwareMap(input.model);
4388
+ } catch (error) {
4389
+ if (error instanceof SoftwareModelValidationError) throw new ReviewInputError(error.message);
4390
+ throw error;
4391
+ }
4392
+ const files = /* @__PURE__ */ new Map();
4393
+ await Promise.all(model.elements.flatMap((element) => (element.sourceRanges ?? []).map(async (source) => {
4394
+ const range = fileLineRangeSchema.parse({
4395
+ ...source,
4396
+ side: input.side
4397
+ });
4398
+ let read = files.get(range.file);
4399
+ if (!read) files.set(range.file, read = this.file(input.pins, range.side, range.file));
4400
+ sliceRange(await read, range);
4401
+ })));
4402
+ data = Buffer.from(JSON.stringify({
4403
+ commit: input.pins[input.side],
4404
+ side: input.side,
4405
+ elements: model.elements,
4406
+ relationships: model.relationships
4407
+ }));
4408
+ break;
4409
+ }
4410
+ }
4411
+ return this.store.putResource(input.id, input.repositoryId, input.kind, mimeType, data);
4412
+ }
4413
+ /** A resource must belong to the document's repository when it has one;
4414
+ * a document without pins may use any registered repository's resources. */
4415
+ async validateResource(pins, block) {
4416
+ const reference = resourceReference(block);
4417
+ if (!reference) return;
4418
+ const { id, kind } = reference;
4419
+ const resource = this.store.resource(id);
4420
+ if (pins && resource.repositoryId !== pins.repositoryId || resource.kind !== kind) throw new ReviewInputError("Resource belongs to a different repository or component type.");
4421
+ if (!pins) this.store.repositoryPath(resource.repositoryId);
4422
+ if (block.type === "trace_quote") {
4423
+ const event = traceSchema.parse(JSON.parse(Buffer.from(resource.data).toString())).events.find((event) => event.id === block.eventId);
4424
+ if (!event || !textIncludesQuote(event.text, block.text)) throw new ReviewInputError("Quote does not match the retained trace event.");
4425
+ }
4426
+ if (block.type === "software_map") {
4427
+ const map = JSON.parse(Buffer.from(resource.data).toString());
4428
+ if (pins && map.commit !== pins[map.side]) throw new ReviewInputError("Map does not match this review's source pins.");
4429
+ if (block.focusElementId && !map.elements.some((element) => element.id === block.focusElementId || element.path === block.focusElementId)) throw new ReviewInputError("Map focus element does not exist.");
4430
+ }
4431
+ }
4432
+ };
4433
+ /** The pure source checks throw their own error; API clients see it as input. */
4434
+ function inputError(run) {
4435
+ try {
4436
+ return run();
4437
+ } catch (error) {
4438
+ if (error instanceof SourceRangeError) throw new ReviewInputError(error.message);
4439
+ throw error;
4440
+ }
4441
+ }
4442
+ function openLocalReviewStore(databasePath, options = {}) {
4443
+ const store = new ReviewStore(databasePath, {
4444
+ projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
4445
+ resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
4446
+ resolvePullRequest: (url, repository) => data.resolvePullRequest(url, repository),
4447
+ headBranch: (pins, headRef) => data.headBranch(pins, headRef),
4448
+ sourcePins: (snapshot) => data.sourcePins(snapshot),
4449
+ unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
4450
+ validatePins: (pins) => data.validatePins(pins),
4451
+ filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
4452
+ validateSource: (pins, source, options) => data.validateSource(pins, source, options),
4453
+ validateResource: (pins, block) => data.validateResource(pins, block)
4454
+ });
4455
+ let data;
4456
+ try {
4457
+ data = new LocalReviewData(store, {
4458
+ ...options,
4459
+ workspaceDatabase: `${databasePath}.workspaces`
4460
+ });
4461
+ } catch (error) {
4462
+ store.close();
4463
+ throw error;
4464
+ }
4465
+ return {
4466
+ store,
4467
+ data
4468
+ };
4469
+ }
4470
+ /** The parts of a VS Code workspace file the navigator owns; everything else
4471
+ * VS Code or the user adds is kept as is. */
4472
+ const codeWorkspaceSchema = z.looseObject({
4473
+ folders: z.array(z.json()).default([]),
4474
+ settings: z.record(z.string(), z.json()).default({})
4475
+ });
4476
+ /** A workspace file's contents, undefined when it does not exist, or null when
4477
+ * it is not plain JSON (VS Code accepts comments), which is left untouched. */
4478
+ async function readWorkspace(file) {
4479
+ const text = await readFile(file, "utf8").catch((error) => {
4480
+ if (isMissingFileError(error)) return void 0;
4481
+ throw error;
4482
+ });
4483
+ if (text === void 0) return void 0;
4484
+ try {
4485
+ return codeWorkspaceSchema.safeParse(JSON.parse(text)).data ?? null;
4486
+ } catch {
4487
+ return null;
4488
+ }
4489
+ }
4490
+ //#endregion
4491
+ export { invalidateStructuralComparisons as _, inspectSnapshot as a, installFullDiffr as b, askAgentIds as c, askQuestionSchema as d, AgentSelectionSchema as f, activityUpdateSchema as g, activityEndSchema as h, commandSchema as i, askChoiceKinds as l, activityBeginSchema as m, uploadSchema as n, initializeReviewStoreSchema as o, selectionMarkdown as p, SCRATCHPAD_ID as r, applyAskChange as s, openLocalReviewStore as t, askPicksSchema as u, diffrExecutable as v, diffrMissingError as y };