@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

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 (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -1,22 +1,24 @@
1
- import { _ as parseGitRemote, a as diff, b as resolveRepoContextSync, c as diffFileSummariesWorkingTree, d as git, g as listTrackedFilesAtCommit, h as listCommitRange, i as detectLocalVcs, l as diffTrees, m as jjRevisionIsConflicted, n as createBlobBatchReader, p as gitCommonDir, s as diffFileSummariesTrees, t as LocalVcsToolsMissingError, u as diffWorkingTree, v as readFileAtCommit, x as resolveRevision, y as resolveRepoContext } from "./src-BMPuxWv4.js";
2
- import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
3
- import { N as decodeStructuralDiffEvent, P as structuralChangeCounts, j as SCRATCHPAD_REVIEW_ID } from "./src-DsM3jO8G.js";
4
- import { s as safeStorageSegment } from "./review-home-paths-Lc02WFuX.js";
5
- import { $ as intersectIntervals, A as documentAdvice, B as requireVisibleSource, C as resourceReference, D as sourceReferences, I as lensSelections, L as SourceRangeError, N as applyLensEdit, O as summarizeEdit, P as lensEditSchema, Q as emptyCoverage, R as checkSourcePath, T as reviewTargetSchema, V as sliceSourceRange, X as coverageSchema, Y as sourceAnchors, a as defineSoftwareMap, b as pinnedRepositoryIds, c as traceSchema, d as applyEdit, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, k as checkReferences, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, nt as unionIntervals, p as documentSchema, r as migrateStoredLenses, rt as updateCoverage, t as liftFileLenses, tt as subtractIntervals, u as anchorPins, v as isUnit, w as resourceReferences, x as pinsSchema, z as fileLineRangeSchema } from "./stored-document-migration-CChxtFAy.js";
1
+ import { C as resolveRevision, D as runVcsSync, E as runVcs, S as resolveRepoContextSync, T as renderVcsArgs, _ as listTrackedFilesAtCommit, a as diff, b as readFileAtCommit, c as diffFileSummariesWorkingTree, d as git, g as listCommitRange, h as jjRevisionIsConflicted, i as detectLocalVcs, l as diffTrees, m as gitCommonDirSync, n as createBlobBatchReader, p as gitCommonDir, s as diffFileSummariesTrees, t as LocalVcsToolsMissingError, u as diffWorkingTree, v as parseGitRemote, x as resolveRepoContext, y as parseGitRemoteSlug } from "./src-WPaQn75m.js";
2
+ import { c as jsonString, m as isStringValue, n as isJsonObject, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
3
+ import { F as structuralChangeCounts, P as decodeStructuralDiffEvent, c as boardIconKind, l as boardTitle } from "./src-BiWNbcF_.js";
4
+ import { s as safeStorageSegment } from "./review-home-paths-D1JVitqN.js";
5
+ import { b as updateCoverage, c as parseAnchor, f as sourceAnchors, g as intersectIntervals, h as emptyCoverage, p as coverageSchema, v as subtractIntervals, y as unionIntervals } from "./lens-selection-CyJapcsl.js";
6
+ import { $ as parseMarkdown, A as resourceReferences, E as pinsSchema, G as lensSelections, H as applyLensEdit, I as sourceReferences, J as fileLineRangeSchema, K as SourceRangeError, L as summarizeEdit, M as respellDocumentLinks, N as respellEditLinks, P as reviewTargetSchema, Q as markdownText, R as checkReferences, S as explicitPins, T as pinnedRepositoryIds, U as lensEditSchema, X as sliceSourceRange, Y as requireVisibleSource, Z as markdownNodes, _ as boardRepositoryId, a as SoftwareModelValidationError, b as editSchema, d as adoptFlowUnits, f as anchorPins, g as boardLinks, h as assignMissingIds, i as readStoredDocument, j as respellCommitLinks, k as resourceReference, l as traceSchema, m as assignFreshIds, n as migrateStoredDocument, o as defineSoftwareMap, p as applyEdit, q as checkSourcePath, r as migrateStoredLenses, u as textIncludesQuote, v as commitLinkCommits, w as pinDocumentReferences, x as elements, y as documentSchema, z as documentAdvice } from "./stored-document-migration-dFbewRTG.js";
6
7
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
7
- import { A as withFileLock, I as writePrivateJsonAtomic, O as processIsAlive } from "./s3-config-DDWWMh5x.js";
8
+ import { A as processStartIdentity, L as writePrivateJsonAtomic, O as pidReused, j as withFileLock, k as processIsAlive } from "./s3-config-BTO12tCC.js";
8
9
  import { t as errorMessage } from "./error-message-OtiDonty.js";
9
10
  import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
10
- import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-5pYr6_zM.js";
11
+ import { a as askHistoryEntrySchema, i as askEntrySchema, o as askOfferSchema, s as askPermissionsSchema } from "./thread-state-Do5LX10I.js";
11
12
  import { createRequire } from "node:module";
12
13
  import crypto, { createHash, randomUUID } from "node:crypto";
13
14
  import { z } from "zod";
14
- import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
15
+ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
15
16
  import * as fs$1 from "node:fs/promises";
16
17
  import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
17
18
  import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
19
+ import { homedir } from "node:os";
18
20
  import { execFile, spawn } from "node:child_process";
19
- import { promisify } from "node:util";
21
+ import { isDeepStrictEqual, promisify } from "node:util";
20
22
  import { DatabaseSync } from "node:sqlite";
21
23
  import { createInterface } from "node:readline";
22
24
  //#region ../../diffr/diffr-ts/dist/binary.js
@@ -93,6 +95,329 @@ function structuralRows(diff) {
93
95
  return rows;
94
96
  }
95
97
  //#endregion
98
+ //#region src/review-api/checkout-groups.ts
99
+ /** A command's output, or undefined if it fails; the runner's rules (no
100
+ * lazy fetch, no prompt, a timeout) apply. */
101
+ function quietly(tool, args, options = {}) {
102
+ try {
103
+ return runVcsSync(tool, args, {
104
+ cwd: options.cwd,
105
+ input: options.input,
106
+ encoding: "utf8",
107
+ stdio: [
108
+ options.input === void 0 ? "ignore" : "pipe",
109
+ "pipe",
110
+ "ignore"
111
+ ],
112
+ maxBuffer: 67108864
113
+ });
114
+ } catch {
115
+ return;
116
+ }
117
+ }
118
+ /** A path with its parent's symlinks resolved, for a path that may be gone. */
119
+ function canonicalPath(file) {
120
+ try {
121
+ return path.join(realpathSync(path.dirname(file)), path.basename(file));
122
+ } catch {
123
+ return path.resolve(file);
124
+ }
125
+ }
126
+ /** The git directory a checkout's worktrees share, read from its `.git`
127
+ * without running git; undefined when that can't be read so. */
128
+ function commonDirOf(root) {
129
+ const dotGit = path.join(root, ".git");
130
+ try {
131
+ if (statSync(dotGit).isDirectory()) return realpathSync(dotGit);
132
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
133
+ if (!gitdir) return void 0;
134
+ const own = path.resolve(root, gitdir.trim());
135
+ try {
136
+ return realpathSync(path.resolve(own, readFileSync(path.join(own, "commondir"), "utf8").trim()));
137
+ } catch {
138
+ return realpathSync(own);
139
+ }
140
+ } catch {
141
+ return;
142
+ }
143
+ }
144
+ /** The origin remote's URL from a git config file; undefined when the file
145
+ * can't settle it (a URL rewrite, an include), so git must be asked. */
146
+ function originUrlOf(commonDir) {
147
+ let config;
148
+ try {
149
+ config = readFileSync(path.join(commonDir, "config"), "utf8");
150
+ } catch {
151
+ return;
152
+ }
153
+ if (/insteadof|^\s*\[include/im.test(config)) return void 0;
154
+ const section = /^\s*\[remote\s+"origin"\]([^[]*)/im.exec(config)?.[1];
155
+ return section ? /^\s*url\s*=\s*(.+)$/im.exec(section)?.[1]?.trim() ?? null : null;
156
+ }
157
+ /** A checkout's repository group; undefined if it isn't a readable git
158
+ * checkout. Reads `.git` and its config directly where it can, so many
159
+ * checkouts group in a moment; git answers the rest. */
160
+ function repositoryGroupAt(root) {
161
+ if (!existsSync(root)) return void 0;
162
+ const commonDir = commonDirOf(root);
163
+ const origin = commonDir ? originUrlOf(commonDir) : void 0;
164
+ const context = commonDir && origin !== void 0 ? {
165
+ commonDir,
166
+ githubSlug: origin ? parseGitRemoteSlug(origin) : null
167
+ } : resolveRepoContextSync(root);
168
+ if (!context) return void 0;
169
+ return context.githubSlug ? {
170
+ key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
171
+ label: context.githubSlug
172
+ } : {
173
+ key: `git:${context.commonDir}`,
174
+ label: path.basename(path.dirname(context.commonDir))
175
+ };
176
+ }
177
+ /** The probe that reads `.git` directly, running git only where it must. */
178
+ const gitCheckoutProbe = {
179
+ groupAt: repositoryGroupAt,
180
+ worktreesOf(root) {
181
+ const commonDir = commonDirOf(root) ?? gitCommonDirSync(root) ?? void 0;
182
+ if (!commonDir) return void 0;
183
+ let entries = [];
184
+ try {
185
+ entries = readdirSync(path.join(commonDir, "worktrees"));
186
+ } catch {}
187
+ return {
188
+ commonDir,
189
+ worktrees: entries.flatMap((entry) => {
190
+ try {
191
+ const gitdir = readFileSync(path.join(commonDir, "worktrees", entry, "gitdir"), "utf8").trim();
192
+ return [path.dirname(gitdir)];
193
+ } catch {
194
+ return [];
195
+ }
196
+ })
197
+ };
198
+ },
199
+ commitsIn: (commonDir, commits) => commitsListed(quietly("git", [
200
+ "--git-dir",
201
+ commonDir,
202
+ "cat-file",
203
+ "--batch-check"
204
+ ], { input: commits.join("\n") + "\n" })),
205
+ findCommits: async (commonDir, commits) => commitsListed(await runVcs("git", [
206
+ "--git-dir",
207
+ commonDir,
208
+ "cat-file",
209
+ "--batch-check"
210
+ ], {
211
+ input: commits.join("\n") + "\n",
212
+ maxBuffer: 67108864
213
+ }).then(({ stdout }) => stdout, () => void 0))
214
+ };
215
+ /** How many repositories are asked about commits at once. */
216
+ const PARALLEL_GIT = 8;
217
+ /**
218
+ * How a store's checkouts group into projects, worked out in one pass and
219
+ * kept for the life of the process. `prepare` asks each checkout still on
220
+ * disk for its group and its worktrees, once each, before an upgrade takes
221
+ * the write lock; projects are then assigned from the maps it builds,
222
+ * without touching git. A deleted checkout joins the checkout that lists it
223
+ * as a worktree, else one whose repository has the commits its boards
224
+ * pinned (a pruned worktree's history lives on in its repository),
225
+ * preferring the one whose folder held it; unmatched, it gets its own
226
+ * project.
227
+ */
228
+ var CheckoutGroups = class {
229
+ db;
230
+ probe;
231
+ groups = /* @__PURE__ */ new Map();
232
+ owners;
233
+ constructor(db, probe = gitCheckoutProbe, prepared) {
234
+ this.db = db;
235
+ this.probe = probe;
236
+ this.owners = prepared;
237
+ }
238
+ /** A checkout's group, asked once per path. */
239
+ group(root) {
240
+ if (!this.groups.has(root)) {
241
+ let group;
242
+ if (existsSync(root)) try {
243
+ group = this.probe.groupAt(root);
244
+ } catch {
245
+ group = void 0;
246
+ }
247
+ this.groups.set(root, group);
248
+ }
249
+ return this.groups.get(root);
250
+ }
251
+ /** A file as a commit has it, or undefined if this repository can't say
252
+ * without fetching. */
253
+ fileAt(root, commit, file) {
254
+ return quietly("git", [
255
+ "-C",
256
+ root,
257
+ "show",
258
+ { rev: `${commit}:${file}` }
259
+ ]);
260
+ }
261
+ /** Works everything out now, ahead of a migration that needs it. */
262
+ prepare() {
263
+ if (this.owners) return;
264
+ const finding = this.findOwners();
265
+ let step = finding.next();
266
+ while (!step.done) step = finding.next(new Map([...step.value].map(([commonDir, commits]) => [commonDir, this.commitsIn(commonDir, commits)])));
267
+ this.owners = step.value;
268
+ }
269
+ /** As `prepare`, asking several repositories at once: before a store
270
+ * opens, while nothing waits on its write lock yet. */
271
+ async prepareConcurrently() {
272
+ if (this.owners) return;
273
+ const finding = this.findOwners();
274
+ let step = finding.next();
275
+ while (!step.done) {
276
+ const questions = [...step.value];
277
+ const answers = /* @__PURE__ */ new Map();
278
+ const ask = async () => {
279
+ for (let next = questions.shift(); next; next = questions.shift()) {
280
+ const [commonDir, commits] = next;
281
+ answers.set(commonDir, await this.findCommits(commonDir, commits));
282
+ }
283
+ };
284
+ await Promise.all(Array.from({ length: PARALLEL_GIT }, ask));
285
+ step = finding.next(answers);
286
+ }
287
+ this.owners = step.value;
288
+ }
289
+ /** The grouping `prepare` worked out, to hand to another connection's. */
290
+ get prepared() {
291
+ return this.owners;
292
+ }
293
+ ownerOf(repositoryId) {
294
+ this.prepare();
295
+ return this.owners.get(repositoryId);
296
+ }
297
+ repositories() {
298
+ return this.db.prepare("SELECT id, path FROM repositories").all();
299
+ }
300
+ commitsIn(commonDir, commits) {
301
+ try {
302
+ return this.probe.commitsIn(commonDir, commits);
303
+ } catch {
304
+ return /* @__PURE__ */ new Set();
305
+ }
306
+ }
307
+ async findCommits(commonDir, commits) {
308
+ try {
309
+ return await (this.probe.findCommits?.(commonDir, commits) ?? this.probe.commitsIn(commonDir, commits));
310
+ } catch {
311
+ return /* @__PURE__ */ new Set();
312
+ }
313
+ }
314
+ /** Works out each deleted checkout's owner, yielding the questions for
315
+ * git it needs answered: once about the commits boards pin now, and
316
+ * again about older ones only for a checkout those leave unsettled. */
317
+ *findOwners() {
318
+ const owners = /* @__PURE__ */ new Map();
319
+ const existing = [];
320
+ const deleted = [];
321
+ for (const { id, path: root } of this.repositories()) (existsSync(String(root)) ? existing : deleted).push({
322
+ id: String(id),
323
+ root: String(root)
324
+ });
325
+ const listed = /* @__PURE__ */ new Map();
326
+ const repositories = /* @__PURE__ */ new Map();
327
+ const repositoryOf = /* @__PURE__ */ new Map();
328
+ for (const checkout of existing) {
329
+ this.group(checkout.root);
330
+ if (!deleted.length) continue;
331
+ let found;
332
+ try {
333
+ found = this.probe.worktreesOf(checkout.root);
334
+ } catch {
335
+ found = void 0;
336
+ }
337
+ if (!found) continue;
338
+ repositoryOf.set(checkout.id, found.commonDir);
339
+ if (repositories.has(found.commonDir)) continue;
340
+ repositories.set(found.commonDir, checkout.id);
341
+ for (const worktree of found.worktrees) listed.set(canonicalPath(worktree), checkout.id);
342
+ }
343
+ for (const gone of deleted) {
344
+ const owner = listed.get(canonicalPath(gone.root));
345
+ if (owner) owners.set(gone.id, owner);
346
+ }
347
+ const folders = existing.map((checkout) => ({
348
+ id: checkout.id,
349
+ folder: canonicalPath(checkout.root) + path.sep
350
+ }));
351
+ const containers = (gone) => {
352
+ const at = canonicalPath(gone.root);
353
+ return folders.flatMap(({ id, folder }) => at.startsWith(folder) && repositoryOf.has(id) ? [{
354
+ id,
355
+ commonDir: repositoryOf.get(id)
356
+ }] : []);
357
+ };
358
+ const unmatched = deleted.filter(({ id }) => !owners.has(id));
359
+ const now = this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "current");
360
+ const historyFirst = unmatched.some(({ id }) => !now.has(id));
361
+ const pinned = historyFirst ? this.pinnedCommits(new Set(unmatched.map(({ id }) => id)), "all") : now;
362
+ const having = /* @__PURE__ */ new Map();
363
+ if (pinned.size) {
364
+ const answers = yield questionsFor(repositories, pinned);
365
+ for (const [gone, theirs] of pinned) having.set(gone, repositoriesWith(repositories, answers, theirs));
366
+ }
367
+ const unsettled = historyFirst ? [] : unmatched.filter(({ id, root }) => {
368
+ const first = containers({ root })[0]?.commonDir ?? repositories.keys().next().value;
369
+ return !having.get(id)?.includes(first ?? "");
370
+ });
371
+ if (unsettled.length) {
372
+ const older = new Map([...this.pinnedCommits(new Set(unsettled.map(({ id }) => id)), "all")].map(([gone, commits]) => [gone, commits.filter((commit) => !now.get(gone)?.includes(commit))]));
373
+ const answers = yield questionsFor(repositories, older);
374
+ for (const [gone, theirs] of older) {
375
+ const more = repositoriesWith(repositories, answers, theirs);
376
+ const known = having.get(gone) ?? [];
377
+ having.set(gone, [...repositories.keys()].filter((commonDir) => known.includes(commonDir) || more.includes(commonDir)));
378
+ }
379
+ }
380
+ for (const gone of unmatched) {
381
+ const withCommits = having.get(gone.id);
382
+ if (!withCommits?.length) continue;
383
+ const container = containers(gone).find(({ commonDir }) => withCommits.includes(commonDir));
384
+ owners.set(gone.id, container?.id ?? repositories.get(withCommits[0]));
385
+ }
386
+ return owners;
387
+ }
388
+ /** The commits each of these checkouts' boards pin: in their current
389
+ * versions, or in every version. */
390
+ pinnedCommits(checkouts, versions) {
391
+ const pinned = /* @__PURE__ */ new Map();
392
+ if (!checkouts.size) return pinned;
393
+ for (const row of this.db.prepare(`SELECT json_extract(snapshot,'$.pins.repositoryId') AS repository,
394
+ json_extract(snapshot,'$.pins.head') AS head,
395
+ json_extract(snapshot,'$.pins.base') AS base
396
+ FROM ${versions === "current" ? "reviews CROSS JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version" : "versions"}
397
+ WHERE json_valid(snapshot) AND json_type(snapshot,'$.pins')='object'`).all()) {
398
+ const repository = String(row.repository);
399
+ if (!checkouts.has(repository)) continue;
400
+ const commits = pinned.get(repository) ?? [];
401
+ for (const commit of [String(row.head), String(row.base)]) if (/^[0-9a-f]{40}$/.test(commit) && !/^0+$/.test(commit) && !commits.includes(commit)) commits.push(commit);
402
+ if (commits.length) pinned.set(repository, commits);
403
+ }
404
+ return pinned;
405
+ }
406
+ };
407
+ /** Every repository asked about every commit pinned. */
408
+ function questionsFor(repositories, pinned) {
409
+ const commits = [...new Set([...pinned.values()].flat())];
410
+ return new Map(commits.length ? [...repositories.keys()].map((commonDir) => [commonDir, commits]) : []);
411
+ }
412
+ /** The repositories, in order, that have any of these commits. */
413
+ function repositoriesWith(repositories, answers, commits) {
414
+ return [...repositories.keys()].filter((commonDir) => commits.some((commit) => answers.get(commonDir)?.has(commit)));
415
+ }
416
+ /** The commits `git cat-file --batch-check` found. */
417
+ function commitsListed(output) {
418
+ return new Set((output ?? "").split("\n").filter((line) => / commit /.test(line)).map((line) => line.split(" ")[0]));
419
+ }
420
+ //#endregion
96
421
  //#region src/review-checkout-paths.ts
97
422
  /**
98
423
  * Root for Review-owned checkouts. A Review UUID identifies one subtree.
@@ -500,6 +825,7 @@ var StructuralComparisons = class {
500
825
  input.signal.throwIfAborted();
501
826
  const key = JSON.stringify([
502
827
  configGeneration,
828
+ await classificationStamp(input.repositoryPath),
503
829
  input.repositoryPath,
504
830
  input.comparison,
505
831
  input.paths
@@ -530,6 +856,30 @@ var StructuralComparisons = class {
530
856
  }
531
857
  };
532
858
  let configGeneration = 0;
859
+ const classificationFiles = /* @__PURE__ */ new Map();
860
+ /** Git attribute and diffr config files that diffr's classifier reads. */
861
+ function classificationInputs(root) {
862
+ let files = classificationFiles.get(root);
863
+ if (!files) {
864
+ const config = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
865
+ const diffrDirectory = process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
866
+ files = Promise.all([gitCommonDir(root), git(root, [
867
+ "config",
868
+ "--path",
869
+ "core.attributesFile"
870
+ ], { allowFailure: true })]).then(([common, user]) => [
871
+ ...common ? [path.join(common, "info", "attributes")] : [],
872
+ path.join(root, ".gitattributes"),
873
+ user.stdout.trim() || path.join(config, "git", "attributes"),
874
+ path.join(diffrDirectory, "config.toml")
875
+ ]);
876
+ classificationFiles.set(root, files);
877
+ }
878
+ return files;
879
+ }
880
+ async function classificationStamp(root) {
881
+ return Promise.all((await classificationInputs(root)).map((file) => stat(file).then(({ mtimeMs, size }) => [mtimeMs, size], () => null)));
882
+ }
533
883
  function invalidateStructuralComparisons() {
534
884
  configGeneration++;
535
885
  }
@@ -1226,26 +1576,31 @@ const mapInputSchema = z.strictObject({
1226
1576
  });
1227
1577
  //#endregion
1228
1578
  //#region src/review-api/pull-request.ts
1579
+ const execFileAsync = promisify(execFile);
1229
1580
  const FETCH_TIMEOUT_MS = 12e4;
1230
1581
  const LOCAL_TIMEOUT_MS = 3e4;
1231
1582
  const defaultPullRequestDeps = {
1232
- run: (file, args, options) => new Promise((resolve, reject) => {
1233
- execFile(file, args, {
1583
+ run: async (file, args, options) => {
1584
+ const spawnOptions = {
1234
1585
  cwd: options.cwd,
1235
1586
  timeout: options.timeoutMs,
1236
1587
  maxBuffer: 16777216,
1237
1588
  env: {
1238
1589
  ...process.env,
1239
- GIT_TERMINAL_PROMPT: "0",
1240
- GH_PROMPT_DISABLED: "1",
1241
1590
  GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes"
1242
1591
  }
1243
- }, (error, stdout, stderr) => {
1244
- if (!error) return resolve(stdout);
1245
- const reason = error.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(stderr).trim() || error.message;
1246
- reject(/* @__PURE__ */ new Error(`${file} ${args[0]}: ${reason}`));
1247
- });
1248
- }),
1592
+ };
1593
+ try {
1594
+ return (file === "az" || file === "cmd.exe" ? await execFileAsync(file, renderVcsArgs(args), {
1595
+ ...spawnOptions,
1596
+ encoding: "utf8"
1597
+ }) : await runVcs(file, args, spawnOptions)).stdout;
1598
+ } catch (error) {
1599
+ const failure = error;
1600
+ const reason = failure.killed ? `timed out after ${options.timeoutMs / 1e3}s` : String(failure.stderr ?? "").trim() || errorMessage(error);
1601
+ throw new Error(`${file} ${String(args[0])}: ${reason}`);
1602
+ }
1603
+ },
1249
1604
  fetch: (input, init) => fetch(input, init)
1250
1605
  };
1251
1606
  /** Remotes by their configured URL, not the insteadOf rewrite: a mirror
@@ -1298,32 +1653,45 @@ async function fetchPullRequest(input, deps) {
1298
1653
  "--no-tags",
1299
1654
  "--no-write-fetch-head",
1300
1655
  "--quiet",
1301
- input.remote,
1656
+ { remote: input.remote },
1302
1657
  ...refspecs
1303
1658
  ], FETCH_TIMEOUT_MS);
1304
1659
  const commit = (ref) => git([
1305
1660
  "rev-parse",
1306
1661
  "--verify",
1307
1662
  "--quiet",
1308
- `${ref}^{commit}`
1663
+ { rev: `${ref}^{commit}` }
1309
1664
  ]).catch(() => void 0);
1310
1665
  const mergeBase = (left, right) => git([
1311
1666
  "merge-base",
1312
- left,
1313
- right
1667
+ { rev: left },
1668
+ { rev: right }
1314
1669
  ]).catch(() => void 0);
1315
1670
  const frozenBase = async () => {
1316
1671
  if (!pr.baseRefOid) return void 0;
1317
- if (!await commit(pr.baseRefOid)) await fetchRefs(`+${pr.baseRefOid}:${refs.frozenBase}`).catch(() => void 0);
1672
+ if (!await commit(pr.baseRefOid)) await fetchRefs({ refspec: {
1673
+ from: pr.baseRefOid,
1674
+ to: refs.frozenBase,
1675
+ force: true
1676
+ } }).catch(() => void 0);
1318
1677
  return commit(pr.baseRefOid);
1319
1678
  };
1320
1679
  let baseTip;
1680
+ const pullHead = { refspec: {
1681
+ from: pr.headRef,
1682
+ to: refs.head,
1683
+ force: true
1684
+ } };
1321
1685
  try {
1322
- await fetchRefs(`+${pr.headRef}:${refs.head}`, `+refs/heads/${pr.baseRefName}:${refs.base}`);
1686
+ await fetchRefs(pullHead, { refspec: {
1687
+ from: `refs/heads/${pr.baseRefName}`,
1688
+ to: refs.base,
1689
+ force: true
1690
+ } });
1323
1691
  baseTip = await commit(refs.base);
1324
1692
  } catch (error) {
1325
1693
  try {
1326
- await fetchRefs(`+${pr.headRef}:${refs.head}`);
1694
+ await fetchRefs(pullHead);
1327
1695
  } catch {
1328
1696
  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);
1329
1697
  }
@@ -1365,8 +1733,8 @@ async function indexForJj(rootPath, commits, git, deps) {
1365
1733
  try {
1366
1734
  for (const [index, tag] of tags.entries()) await git([
1367
1735
  "update-ref",
1368
- tag,
1369
- commits[index]
1736
+ { ref: tag },
1737
+ { commit: commits[index] }
1370
1738
  ]);
1371
1739
  await jjImport();
1372
1740
  } finally {
@@ -1378,6 +1746,15 @@ async function indexForJj(rootPath, commits, git, deps) {
1378
1746
  await jjImport().catch(() => void 0);
1379
1747
  }
1380
1748
  }
1749
+ /** GitHub refused a read, as it does past a rate limit; it takes reads
1750
+ * again at `retryAt`. */
1751
+ var GitHubRefusedRead = class extends ReviewInputError {
1752
+ retryAt;
1753
+ constructor(message, retryAt) {
1754
+ super(message, 409);
1755
+ this.retryAt = retryAt;
1756
+ }
1757
+ };
1381
1758
  function firstLine(text) {
1382
1759
  return text.split("\n")[0].slice(0, 300);
1383
1760
  }
@@ -1494,8 +1871,20 @@ const metadataSchema = z.object({
1494
1871
  baseRefName: z.string().min(1),
1495
1872
  baseRefOid: z.string().optional(),
1496
1873
  headRefName: z.string().min(1),
1497
- isCrossRepository: z.boolean()
1874
+ isCrossRepository: z.boolean(),
1875
+ state: z.enum([
1876
+ "OPEN",
1877
+ "CLOSED",
1878
+ "MERGED"
1879
+ ]).optional(),
1880
+ isDraft: z.boolean().optional()
1498
1881
  });
1882
+ /** gh's state and draft flag as one state. */
1883
+ function ghState(state, isDraft) {
1884
+ if (state === "MERGED") return "merged";
1885
+ if (state === "CLOSED") return "closed";
1886
+ if (state === "OPEN") return isDraft ? "draft" : "open";
1887
+ }
1499
1888
  function unreadable(url, ghFailure, fallback) {
1500
1889
  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);
1501
1890
  }
@@ -1523,19 +1912,21 @@ const providers = [{
1523
1912
  const stdout = await deps.run("gh", [
1524
1913
  "pr",
1525
1914
  "view",
1526
- String(number),
1915
+ { value: String(number) },
1527
1916
  "--repo",
1528
- `${host}/${slug}`,
1917
+ { value: `${host}/${slug}` },
1529
1918
  "--json",
1530
- "number,title,baseRefName,baseRefOid,headRefName,isCrossRepository"
1919
+ "number,title,baseRefName,baseRefOid,headRefName,isCrossRepository,state,isDraft"
1531
1920
  ], { timeoutMs: GH_TIMEOUT_MS });
1532
- const { headRefName, isCrossRepository, ...metadata } = metadataSchema.parse(JSON.parse(stdout));
1921
+ const { headRefName, isCrossRepository, state, isDraft, ...metadata } = metadataSchema.parse(JSON.parse(stdout));
1922
+ const pullRequestState = ghState(state, isDraft);
1533
1923
  return {
1534
1924
  host,
1535
1925
  slug,
1536
1926
  headRef,
1537
1927
  ...metadata,
1538
- headBranch: isCrossRepository ? void 0 : headRefName
1928
+ headBranch: isCrossRepository ? void 0 : headRefName,
1929
+ ...pullRequestState && { state: pullRequestState }
1539
1930
  };
1540
1931
  } catch (error) {
1541
1932
  ghFailure = firstLine(errorMessage(error));
@@ -1554,7 +1945,10 @@ const providers = [{
1554
1945
  throw unreadable(url, ghFailure, `the GitHub API is unreachable (${firstLine(errorMessage(error))})`);
1555
1946
  }
1556
1947
  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);
1557
- 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");
1948
+ if (response.status === 403 || response.status === 429) {
1949
+ const reset = Number(response.headers.get("x-ratelimit-reset"));
1950
+ throw new GitHubRefusedRead(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").message, reset > 0 ? reset * 1e3 : Date.now() + 9e5);
1951
+ }
1558
1952
  if (!response.ok) throw unreadable(url, ghFailure, `the GitHub API returned HTTP ${response.status}`);
1559
1953
  const parsed = z.object({
1560
1954
  number: z.number(),
@@ -1567,7 +1961,10 @@ const providers = [{
1567
1961
  head: z.object({
1568
1962
  ref: z.string().min(1),
1569
1963
  repo: z.object({ id: z.number() }).nullable()
1570
- })
1964
+ }),
1965
+ state: z.enum(["open", "closed"]).optional(),
1966
+ draft: z.boolean().optional(),
1967
+ merged_at: z.string().nullable().optional()
1571
1968
  }).safeParse(await response.json().catch(() => void 0));
1572
1969
  if (!parsed.success) throw unreadable(url, ghFailure, "the GitHub API response was malformed");
1573
1970
  return {
@@ -1578,7 +1975,8 @@ const providers = [{
1578
1975
  title: parsed.data.title,
1579
1976
  baseRefName: parsed.data.base.ref,
1580
1977
  baseRefOid: parsed.data.base.sha,
1581
- headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0
1978
+ headBranch: parsed.data.head.repo?.id === parsed.data.base.repo.id ? parsed.data.head.ref : void 0,
1979
+ ...parsed.data.state && { state: parsed.data.merged_at ? "merged" : parsed.data.state === "closed" ? "closed" : parsed.data.draft ? "draft" : "open" }
1582
1980
  };
1583
1981
  },
1584
1982
  matchesRemote(remoteUrl, address) {
@@ -1630,8 +2028,7 @@ var ReviewActivity = class {
1630
2028
  this.assertReview = assertReview;
1631
2029
  db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
1632
2030
  activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
1633
- started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
1634
- DROP TABLE IF EXISTS authoring_sessions;`);
2031
+ started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);`);
1635
2032
  for (const row of db.prepare("SELECT DISTINCT review_id FROM authoring_presences WHERE expires_at>?").all(Date.now())) {
1636
2033
  this.working.add(String(row.review_id));
1637
2034
  this.scheduleExpiry(String(row.review_id));
@@ -1774,7 +2171,14 @@ var ReviewActivity = class {
1774
2171
  //#region src/review-api/ask-history.ts
1775
2172
  /** A saved Ask conversation: its history entry, and how to reopen it. */
1776
2173
  const askRecordSchema = askHistoryEntrySchema.extend({
1777
- reviewId: z.string(),
2174
+ /** The board the conversation started on, and the block if any. */
2175
+ anchor: z.object({
2176
+ board: z.string(),
2177
+ block: z.string().optional()
2178
+ }),
2179
+ /** The project of the board it started on, read from that board, or its
2180
+ * tombstone once deleted; absent for a shared board. */
2181
+ projectId: z.string().optional(),
1778
2182
  /** The ACP session the agent keeps the transcript under. */
1779
2183
  sessionId: z.string(),
1780
2184
  /** The review version asked about; its pins recreate the checkout. */
@@ -1787,7 +2191,9 @@ const askRecordSchema = askHistoryEntrySchema.extend({
1787
2191
  });
1788
2192
  const rowSchema = z.object({
1789
2193
  id: z.string(),
1790
- review_id: z.string(),
2194
+ anchor_board: z.string(),
2195
+ anchor_block: z.string().nullable(),
2196
+ project_id: z.string().nullable(),
1791
2197
  agent: z.string(),
1792
2198
  session_id: z.string(),
1793
2199
  version: z.number(),
@@ -1802,7 +2208,11 @@ const rowSchema = z.object({
1802
2208
  permissions: z.string().nullable()
1803
2209
  }).transform((row) => askRecordSchema.parse({
1804
2210
  id: row.id,
1805
- reviewId: row.review_id,
2211
+ anchor: row.anchor_block === null ? { board: row.anchor_board } : {
2212
+ board: row.anchor_board,
2213
+ block: row.anchor_block
2214
+ },
2215
+ projectId: row.project_id ?? void 0,
1806
2216
  agent: row.agent,
1807
2217
  sessionId: row.session_id,
1808
2218
  version: row.version,
@@ -1815,19 +2225,32 @@ const rowSchema = z.object({
1815
2225
  entries: row.entries === null ? void 0 : JSON.parse(row.entries),
1816
2226
  permissions: row.permissions ?? (row.bypass === 1 ? "bypass" : "read-only")
1817
2227
  }));
1818
- /** Saved Ask conversations, per review. Shared reviews are not in
1819
- * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
2228
+ /** A thread's project: its anchor board's, or a deleted board's from its
2229
+ * tombstone. The one place a thread's project comes from, so it can never
2230
+ * disagree with its board's. */
2231
+ const THREAD_PROJECT_SQL = `COALESCE(
2232
+ (SELECT project_id FROM reviews WHERE reviews.id=ask_conversations.anchor_board),
2233
+ (SELECT project_id FROM deleted_boards WHERE deleted_boards.id=ask_conversations.anchor_board))`;
2234
+ /** Every column of a thread, with its project. */
2235
+ const THREAD_COLUMNS = `ask_conversations.*, ${THREAD_PROJECT_SQL} AS project_id`;
2236
+ /** Saved Ask conversations by project, each anchored on the board it started
2237
+ * on. No foreign key on the anchor: shared reviews are not in `reviews`.
2238
+ * So deleting a board removes its threads by hand (removeReview). */
1820
2239
  var AskHistory = class {
1821
2240
  db;
2241
+ writable;
2242
+ listeners = /* @__PURE__ */ new Set();
1822
2243
  externalWriteGuard;
1823
2244
  setExternalWriteGuard(guard) {
1824
2245
  this.externalWriteGuard = guard;
1825
2246
  }
1826
- constructor(db) {
2247
+ constructor(db, writable = () => {}) {
1827
2248
  this.db = db;
2249
+ this.writable = writable;
1828
2250
  db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
1829
2251
  id TEXT PRIMARY KEY,
1830
- review_id TEXT NOT NULL,
2252
+ anchor_board TEXT NOT NULL,
2253
+ anchor_block TEXT,
1831
2254
  agent TEXT NOT NULL,
1832
2255
  session_id TEXT NOT NULL,
1833
2256
  version INTEGER NOT NULL,
@@ -1840,30 +2263,39 @@ var AskHistory = class {
1840
2263
  entries TEXT,
1841
2264
  bypass INTEGER NOT NULL DEFAULT 0,
1842
2265
  permissions TEXT,
1843
- UNIQUE(agent, session_id));
1844
- CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
1845
- const columns = new Set(db.prepare("PRAGMA table_info(ask_conversations)").all().map((column) => String(column.name)));
1846
- if (!columns.has("bypass")) db.exec("ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0");
1847
- if (!columns.has("permissions")) db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
2266
+ viewed_at TEXT,
2267
+ UNIQUE(agent, session_id));`);
2268
+ db.exec("CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(anchor_board, updated_at)");
1848
2269
  db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
1849
2270
  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))");
1850
2271
  }
2272
+ /** Calls back when a conversation is saved, renamed, used or deleted. */
2273
+ subscribe(listener) {
2274
+ this.listeners.add(listener);
2275
+ return () => {
2276
+ this.listeners.delete(listener);
2277
+ };
2278
+ }
2279
+ changed() {
2280
+ for (const listener of this.listeners) listener();
2281
+ }
1851
2282
  assertWritable(reviewId) {
1852
2283
  if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
1853
- const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(reviewId);
1854
- if (row?.deleted_at) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(String(row.deleted_at)).toLocaleDateString("en-US", {
1855
- month: "short",
1856
- day: "numeric"
1857
- })}. Restore it with session_lifecycle({"sessionId":"${reviewId}","state":"active"}) to continue.`, 409);
2284
+ else this.writable(reviewId);
1858
2285
  }
1859
2286
  assertThreadWritable(id) {
1860
2287
  const record = this.get(id);
1861
- if (record) this.assertWritable(record.reviewId);
2288
+ if (record) this.assertWritable(record.anchor.board);
1862
2289
  }
1863
2290
  save(record) {
1864
- this.assertWritable(record.reviewId);
1865
- 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,permissions)
1866
- 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.permissions === "bypass" ? 1 : 0, record.permissions ?? null);
2291
+ this.assertWritable(record.anchor.board);
2292
+ this.db.prepare(`INSERT INTO ask_conversations(id,anchor_board,anchor_block,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
2293
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
2294
+ ON CONFLICT(id) DO UPDATE SET anchor_board=excluded.anchor_board, anchor_block=excluded.anchor_block,
2295
+ agent=excluded.agent, session_id=excluded.session_id, version=excluded.version, head=excluded.head,
2296
+ cwd=excluded.cwd, selection=excluded.selection, title=excluded.title, created_at=excluded.created_at,
2297
+ updated_at=excluded.updated_at, bypass=excluded.bypass, permissions=excluded.permissions`).run(record.id, record.anchor.board, record.anchor.block ?? null, record.agent, record.sessionId, record.version, record.head, record.cwd, JSON.stringify(record.selection), record.title, record.createdAt, record.updatedAt, record.permissions === "bypass" ? 1 : 0, record.permissions ?? null);
2298
+ this.changed();
1867
2299
  }
1868
2300
  /** What the agent may do without asking when the conversation reopens. */
1869
2301
  setPermissions(id, permissions) {
@@ -1880,11 +2312,13 @@ var AskHistory = class {
1880
2312
  saveEntries(id, entries) {
1881
2313
  this.assertThreadWritable(id);
1882
2314
  this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
2315
+ this.changed();
1883
2316
  }
1884
2317
  /** The agent's name for the conversation, which the list shows. */
1885
2318
  rename(id, title) {
1886
2319
  this.assertThreadWritable(id);
1887
2320
  this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
2321
+ this.changed();
1888
2322
  }
1889
2323
  saveOffer(agent, offer) {
1890
2324
  this.db.prepare("INSERT OR REPLACE INTO ask_agent_offers(agent, offer) VALUES(?, ?)").run(agent, JSON.stringify(offer));
@@ -1905,29 +2339,202 @@ var AskHistory = class {
1905
2339
  touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
1906
2340
  this.assertThreadWritable(id);
1907
2341
  this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
1908
- }
1909
- /** Newest first. */
1910
- list(reviewId) {
1911
- return this.db.prepare("SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC").all(reviewId).map((row) => {
1912
- const { reviewId: _reviewId, sessionId: _sessionId, version: _version, cwd: _cwd, entries, permissions: _permissions, ...entry } = rowSchema.parse(row);
1913
- const question = entries?.find((entry) => entry.kind === "user")?.text;
1914
- if (question?.trim()) entry.question = question;
1915
- return entry;
2342
+ this.changed();
2343
+ }
2344
+ /** Conversations in a board's project, or started on the board when it
2345
+ * has none. Newest first. */
2346
+ list(board, projectId) {
2347
+ return this.entries("anchor_board=? OR anchor_board IN (SELECT id FROM reviews WHERE project_id=? UNION ALL SELECT id FROM deleted_boards WHERE project_id=?)", board, projectId ?? null, projectId ?? null);
2348
+ }
2349
+ /** Every project's conversations, newest first. */
2350
+ listAll() {
2351
+ return this.entries("1");
2352
+ }
2353
+ entries(where, ...params) {
2354
+ return this.db.prepare(`SELECT id, anchor_board, anchor_block, ${THREAD_PROJECT_SQL} AS project_id, agent, session_id, version, head, cwd, selection, title,
2355
+ created_at, updated_at, NULL AS entries, bypass, permissions,
2356
+ (SELECT json_extract(value,'$.text') FROM json_each(entries)
2357
+ WHERE json_extract(value,'$.kind')='user' LIMIT 1) AS question
2358
+ FROM ask_conversations WHERE ${where} ORDER BY updated_at DESC`).all(...params).map((row) => {
2359
+ const { anchor, sessionId: _sessionId, version: _version, cwd: _cwd, entries: _entries, permissions: _permissions, ...entry } = rowSchema.parse(row);
2360
+ const question = z.string().nullable().parse(row.question) ?? "";
2361
+ if (question.trim()) entry.question = question;
2362
+ return {
2363
+ ...entry,
2364
+ anchor
2365
+ };
1916
2366
  });
1917
2367
  }
1918
2368
  get(id) {
1919
- const row = this.db.prepare("SELECT * FROM ask_conversations WHERE id=?").get(id);
2369
+ const row = this.db.prepare(`SELECT ${THREAD_COLUMNS} FROM ask_conversations WHERE id=?`).get(id);
1920
2370
  return row ? rowSchema.parse(row) : void 0;
1921
2371
  }
2372
+ /** The reviewer saw the thread, on screen with the app focused. Only
2373
+ * when it was seen changes: not its order, nor when it was used. */
2374
+ markViewed(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
2375
+ this.db.prepare("UPDATE ask_conversations SET viewed_at=? WHERE id=?").run(at, id);
2376
+ this.changed();
2377
+ }
2378
+ /** When each of these threads was last viewed, where it ever was.
2379
+ * Threads from before views were kept have none, and none is unread. */
2380
+ viewedAt(ids) {
2381
+ const select = this.db.prepare("SELECT viewed_at FROM ask_conversations WHERE id=? AND viewed_at IS NOT NULL");
2382
+ return new Map(ids.flatMap((id) => {
2383
+ const row = select.get(id);
2384
+ return row ? [[id, String(row.viewed_at)]] : [];
2385
+ }));
2386
+ }
2387
+ /** Forgets the threads that started on a board gone for good. */
1922
2388
  removeReview(reviewId) {
1923
- this.db.prepare("DELETE FROM ask_conversations WHERE review_id=?").run(reviewId);
2389
+ this.db.prepare("DELETE FROM ask_conversations WHERE anchor_board=?").run(reviewId);
2390
+ this.changed();
1924
2391
  }
1925
2392
  delete(id) {
1926
2393
  this.assertThreadWritable(id);
1927
2394
  this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
2395
+ this.changed();
1928
2396
  }
1929
2397
  };
1930
2398
  //#endregion
2399
+ //#region src/review-api/board-order.ts
2400
+ /**
2401
+ * Siblings in reading order. A comes after B when A reaches B through
2402
+ * depends_on, directly or through boards outside the siblings. Between two
2403
+ * siblings with no such chain either way, commits decide: A comes after B
2404
+ * when A's base is, or descends from, B's head, or A's PR merges into B's
2405
+ * branch. Remaining ties go newest made first, then by id, so an edit
2406
+ * never reorders them.
2407
+ */
2408
+ function orderSiblings(siblings, dependsOn, isAncestor) {
2409
+ const reach = new Map(siblings.map((board) => [board.id, reachable(board.id, dependsOn)]));
2410
+ const after = (a, b) => {
2411
+ if (reach.get(a.id).has(b.id)) return true;
2412
+ if (reach.get(b.id).has(a.id)) return false;
2413
+ return buildsOn(a, b, isAncestor) && !buildsOn(b, a, isAncestor);
2414
+ };
2415
+ const before = new Map(siblings.map((a) => [a.id, new Set(siblings.flatMap((b) => b !== a && after(a, b) ? [b.id] : []))]));
2416
+ const newestFirst = [...siblings].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || a.id.localeCompare(b.id));
2417
+ const placed = /* @__PURE__ */ new Set();
2418
+ const order = [];
2419
+ while (order.length < siblings.length) {
2420
+ const remaining = newestFirst.filter((board) => !placed.has(board.id));
2421
+ const next = remaining.find((board) => [...before.get(board.id)].every((id) => placed.has(id))) ?? remaining[0];
2422
+ placed.add(next.id);
2423
+ order.push(next.id);
2424
+ }
2425
+ return order;
2426
+ }
2427
+ function reachable(id, dependsOn) {
2428
+ const seen = /* @__PURE__ */ new Set();
2429
+ const stack = [...dependsOn(id)];
2430
+ while (stack.length) {
2431
+ const next = stack.pop();
2432
+ if (seen.has(next)) continue;
2433
+ seen.add(next);
2434
+ stack.push(...dependsOn(next));
2435
+ }
2436
+ return seen;
2437
+ }
2438
+ /** Whether a's change sits on top of b's. */
2439
+ function buildsOn(a, b, isAncestor) {
2440
+ if (a.repositoryId !== b.repositoryId) return false;
2441
+ if (a.baseBranch && a.baseBranch === b.headBranch) return true;
2442
+ if (!a.base || !b.head) return false;
2443
+ if (a.base === b.head) return true;
2444
+ return a.base !== a.head && isAncestor(a.repositoryId, b.head, a.base);
2445
+ }
2446
+ /**
2447
+ * The path that adding `from → to` would close, from `from` back to itself,
2448
+ * or undefined when it closes none.
2449
+ */
2450
+ function dependencyCycle(from, to, dependsOn) {
2451
+ if (from === to) return [from, from];
2452
+ const previous = /* @__PURE__ */ new Map();
2453
+ const queue = [to];
2454
+ const seen = /* @__PURE__ */ new Set([to]);
2455
+ while (queue.length) {
2456
+ const current = queue.shift();
2457
+ if (current === from) {
2458
+ const path = [from];
2459
+ for (let at = from; at !== to; at = previous.get(at)) path.unshift(previous.get(at));
2460
+ return [from, ...path];
2461
+ }
2462
+ for (const next of dependsOn(current)) if (!seen.has(next)) {
2463
+ seen.add(next);
2464
+ previous.set(next, current);
2465
+ queue.push(next);
2466
+ }
2467
+ }
2468
+ }
2469
+ //#endregion
2470
+ //#region src/review-api/link-preview.ts
2471
+ /** About what the card shows before it fades; more is cut, not sent. */
2472
+ const PROSE_CHARACTERS = 700;
2473
+ const CODE_LINES = 6;
2474
+ function childBlocks(block) {
2475
+ return "children" in block ? block.children : [];
2476
+ }
2477
+ /** Paragraphs of markdown, without headings. */
2478
+ function paragraphs(markdown) {
2479
+ return markdown.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter((paragraph) => paragraph && !paragraph.startsWith("#"));
2480
+ }
2481
+ /** The prose from the first markdown block on, in reading order, until the
2482
+ * card has enough. Diagrams and code before it are skipped. */
2483
+ function openingProse(blocks) {
2484
+ const prose = [];
2485
+ let length = 0;
2486
+ const walk = (list) => {
2487
+ for (const block of list) if (block.type === "markdown") for (const paragraph of paragraphs(block.markdown)) {
2488
+ prose.push(paragraph);
2489
+ length += paragraph.length;
2490
+ if (length >= PROSE_CHARACTERS) return true;
2491
+ }
2492
+ else if (walk(childBlocks(block))) return true;
2493
+ return false;
2494
+ };
2495
+ walk(blocks);
2496
+ return prose;
2497
+ }
2498
+ /** The block and the title of the section it is in. */
2499
+ function find(blocks, id, section) {
2500
+ for (const block of blocks) {
2501
+ if (block.id === id) return {
2502
+ block,
2503
+ section
2504
+ };
2505
+ const found = find(childBlocks(block), id, block.type === "section" ? block.title : section);
2506
+ if (found) return found;
2507
+ }
2508
+ }
2509
+ /** The opening prose of a board, or one block of it. */
2510
+ function boardLinkPreview(document, blockId) {
2511
+ if (blockId === void 0) return { prose: openingProse(document) };
2512
+ const found = find(document, blockId);
2513
+ if (!found) return {};
2514
+ const { block } = found;
2515
+ const section = block.type === "section" ? block.title : found.section;
2516
+ if (block.type === "code") return {
2517
+ ...section && { section },
2518
+ code: {
2519
+ language: block.language,
2520
+ lines: block.text.split("\n").slice(0, CODE_LINES)
2521
+ }
2522
+ };
2523
+ const prose = openingProse([block]);
2524
+ if (prose.length) return {
2525
+ ...section && { section },
2526
+ prose
2527
+ };
2528
+ const title = "title" in block ? block.title : void 0;
2529
+ return {
2530
+ ...section && { section },
2531
+ figure: {
2532
+ type: block.type,
2533
+ title: title ?? ""
2534
+ }
2535
+ };
2536
+ }
2537
+ //#endregion
1931
2538
  //#region src/review-api/origin.ts
1932
2539
  const pullRequestUrl = z.string().refine((url) => pullRequestProvider(url) !== void 0, "Use a canonical PR URL: https://github.com/owner/repository/pull/123, the same path on a GitHub Enterprise host, or https://dev.azure.com/org/project/_git/repository/pullrequest/123.");
1933
2540
  /** One key per PR: GitHub owner and repository names are case-insensitive,
@@ -1954,36 +2561,496 @@ function setPullRequest(snapshot, url, title) {
1954
2561
  };
1955
2562
  }
1956
2563
  //#endregion
2564
+ //#region src/review-api/foreign-references.ts
2565
+ const LANGUAGES = /* @__PURE__ */ new Map([
2566
+ [".ts", "typescript"],
2567
+ [".tsx", "tsx"],
2568
+ [".js", "javascript"],
2569
+ [".jsx", "jsx"],
2570
+ [".mjs", "javascript"],
2571
+ [".rs", "rust"],
2572
+ [".go", "go"],
2573
+ [".py", "python"],
2574
+ [".rb", "ruby"],
2575
+ [".java", "java"],
2576
+ [".kt", "kotlin"],
2577
+ [".swift", "swift"],
2578
+ [".c", "c"],
2579
+ [".h", "c"],
2580
+ [".cc", "cpp"],
2581
+ [".cpp", "cpp"],
2582
+ [".css", "css"],
2583
+ [".html", "html"],
2584
+ [".json", "json"],
2585
+ [".md", "markdown"],
2586
+ [".sh", "bash"],
2587
+ [".sql", "sql"],
2588
+ [".toml", "toml"],
2589
+ [".yaml", "yaml"],
2590
+ [".yml", "yaml"]
2591
+ ]);
2592
+ /** A source link in Markdown, in either spelling. */
2593
+ const SOURCE_LINK = /^(?:whiteboard:source\/|review-source:)/i;
2594
+ /**
2595
+ * Migrates the old global scratchpad, which quoted several repositories.
2596
+ * A board may only quote its own, so each quote of another repository is
2597
+ * replaced:
2598
+ * - a code peek becomes a `code` block of those lines;
2599
+ * - a source link becomes its text, then what it pointed at in code;
2600
+ * - a diagram attachment leaves the diagram and its lines follow it as
2601
+ * a `code` block;
2602
+ * - a step keeps its label and takes the lines as its code;
2603
+ * - a call stack or database lens becomes a line saying what it was,
2604
+ * then a `code` block for each range it quoted, in any repository; the
2605
+ * first takes its id. A lens that quotes only its own repository is
2606
+ * kept.
2607
+ * New blocks take ids from `allocate`.
2608
+ */
2609
+ function snapshotForeignReferences(document, own, read, repositoryName, allocate) {
2610
+ const foreign = (pins) => isJsonObject(pins) && isStringValue(pins.repositoryId) && pins.repositoryId !== own ? pins : void 0;
2611
+ const caption = (pins, anchor) => {
2612
+ const selection = parseAnchor(anchor);
2613
+ const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
2614
+ const range = selection ? `${selection.file}:${selection.start.line}-${selection.end.line}` : anchor;
2615
+ return `${repositoryName(pins.repositoryId)} · ${range} @ ${commit.slice(0, 7)}`;
2616
+ };
2617
+ const snapshot = (id, pins, anchor) => {
2618
+ const selection = parseAnchor(anchor);
2619
+ const commit = selection?.start.side === "base" ? pins.base ?? pins.head : pins.head;
2620
+ const file = selection ? read(pins.repositoryId, commit, selection.file) : void 0;
2621
+ const label = caption(pins, anchor);
2622
+ const text = file !== void 0 && selection ? file.split("\n").slice(selection.start.line - 1, selection.end.line).join("\n") : `${label}\n(source unavailable)`;
2623
+ return {
2624
+ type: "code",
2625
+ ...isStringValue(id) && { id },
2626
+ language: selection ? LANGUAGES.get(path.extname(selection.file).toLowerCase()) ?? "text" : "text",
2627
+ text,
2628
+ caption: label
2629
+ };
2630
+ };
2631
+ const pinsIn = (pins) => {
2632
+ return isJsonObject(pins) && isStringValue(pins.repositoryId) && isStringValue(pins.head) ? {
2633
+ repositoryId: pins.repositoryId,
2634
+ head: pins.head,
2635
+ ...isStringValue(pins.base) && { base: pins.base }
2636
+ } : void 0;
2637
+ };
2638
+ const pinsOf = (value) => pinsIn(foreign(value));
2639
+ const peeks = (blocks) => blocks.map((block) => {
2640
+ if (!isJsonObject(block)) return block;
2641
+ if (Array.isArray(block.children)) return {
2642
+ ...block,
2643
+ children: peeks(block.children)
2644
+ };
2645
+ const pins = pinsOf(block.pins);
2646
+ if (block.type === "code_peek" && pins && isStringValue(block.source)) return snapshot(block.id, pins, block.source);
2647
+ return block;
2648
+ });
2649
+ const rest = (blocks) => blocks.flatMap((block) => {
2650
+ if (!isJsonObject(block)) return block;
2651
+ if (Array.isArray(block.children)) return {
2652
+ ...block,
2653
+ children: rest(block.children)
2654
+ };
2655
+ const blockPins = pinsOf(block.pins);
2656
+ if (block.type === "markdown" && blockPins && isStringValue(block.markdown)) {
2657
+ const { pins: _pins, ...kept } = block;
2658
+ return {
2659
+ ...kept,
2660
+ markdown: unlinkSourceLinks(block.markdown, blockPins)
2661
+ };
2662
+ }
2663
+ if (block.type === "sequence" && Array.isArray(block.steps)) return {
2664
+ ...block,
2665
+ steps: block.steps.map((step) => {
2666
+ if (!isJsonObject(step) || !isStringValue(step.source)) return step;
2667
+ const pins = pinsOf(step.pins ?? block.pins);
2668
+ if (!pins) return step;
2669
+ const { source, pins: _pins, ...kept } = step;
2670
+ const quoted = snapshot(void 0, pins, source);
2671
+ return isJsonObject(quoted) ? {
2672
+ ...kept,
2673
+ code: {
2674
+ language: quoted.language,
2675
+ text: quoted.text
2676
+ }
2677
+ } : kept;
2678
+ })
2679
+ };
2680
+ if (block.type === "flow_diagram" && Array.isArray(block.nodes)) {
2681
+ const moved = [];
2682
+ return [{
2683
+ ...block,
2684
+ nodes: block.nodes.map((node) => isJsonObject(node) && Array.isArray(node.attachments) ? {
2685
+ ...node,
2686
+ attachments: node.attachments.filter((attachment) => {
2687
+ if (!isJsonObject(attachment) || !pinsOf(attachment.pins ?? block.pins)) return true;
2688
+ for (const [pins, anchor] of quotes(attachment, pinsIn, block.pins)) moved.push(snapshot(allocate("block"), pins, anchor));
2689
+ return false;
2690
+ })
2691
+ } : node)
2692
+ }, ...moved];
2693
+ }
2694
+ if (block.type === "call_stack_diff" || block.type === "database_lens") {
2695
+ const foreignQuote = quotes(block, pinsOf)[0];
2696
+ if (!foreignQuote) return block;
2697
+ const what = block.type === "call_stack_diff" ? "Call stack" : "Database lens";
2698
+ const title = isStringValue(block.title) ? ` “${block.title}”` : "";
2699
+ return [{
2700
+ type: "markdown",
2701
+ id: allocate("block"),
2702
+ markdown: `${what}${title} (from ${repositoryName(foreignQuote[0].repositoryId)}), kept as snapshots below.`
2703
+ }, ...quotes(block, pinsIn).map(([pins, anchor], index) => snapshot(index === 0 ? block.id : allocate("block"), pins, anchor))];
2704
+ }
2705
+ return block;
2706
+ });
2707
+ /** The ranges a block's frames or operations quote at the pins `read`
2708
+ * accepts. */
2709
+ function quotes(block, read, inherited) {
2710
+ if (Array.isArray(block)) return block.flatMap((item) => quotes(item, read, inherited));
2711
+ if (!isJsonObject(block)) return [];
2712
+ const scope = block.pins ?? inherited;
2713
+ const pins = read(scope);
2714
+ return [...pins ? [
2715
+ block.source,
2716
+ block.callSite,
2717
+ ...Array.isArray(block.contextSources) ? block.contextSources : [],
2718
+ ...Array.isArray(block.sources) ? block.sources : []
2719
+ ].flatMap((anchor) => isStringValue(anchor) ? [[pins, anchor]] : []) : [], ...Object.entries(block).filter(([key]) => key !== "pins").flatMap(([, value]) => Array.isArray(value) || isJsonObject(value) ? quotes(value, read, scope) : [])];
2720
+ }
2721
+ function unlinkSourceLinks(markdown, pins) {
2722
+ let result = markdown;
2723
+ const links = [...markdownNodes(parseMarkdown(markdown))].filter((node) => node.type === "link" && SOURCE_LINK.test(node.url ?? "") && node.position?.start.offset !== void 0 && node.position.end.offset !== void 0).reverse();
2724
+ for (const node of links) result = result.slice(0, node.position.start.offset) + `${markdownText(node)} (\`${caption(pins, node.url.replace(SOURCE_LINK, ""))}\`)` + result.slice(node.position.end.offset);
2725
+ return result;
2726
+ }
2727
+ return rest(peeks(document));
2728
+ }
2729
+ //#endregion
1957
2730
  //#region src/review-api/store-schema.ts
2731
+ /** A board version's repository in SQL over `versions.snapshot`, as
2732
+ * `boardRepositoryId` reads it: its target's, else its pins'. */
2733
+ const BOARD_REPOSITORY_ID_SQL = "CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END";
2734
+ const columnsOf = (db, table) => new Set(db.prepare("SELECT name FROM pragma_table_info(?)").all(table).map(({ name }) => String(name)));
1958
2735
  function initializeReviewStoreSchema(db) {
1959
2736
  db.exec(`
1960
2737
  PRAGMA journal_mode=WAL;
1961
2738
  PRAGMA foreign_keys=ON;
1962
- CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL);
2739
+ CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
2740
+ project_id TEXT NOT NULL REFERENCES projects(id), parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL);
1963
2741
  CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
2742
+ thread_id TEXT, author_kind TEXT,
1964
2743
  PRIMARY KEY(review_id,version));
1965
- DROP TABLE IF EXISTS receipts;
1966
2744
  CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
1967
- CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
2745
+ CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT NOT NULL);
2746
+ CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
2747
+ project_id TEXT REFERENCES projects(id));
1968
2748
  CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
1969
2749
  kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
1970
2750
  CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
1971
2751
  PRIMARY KEY(review_id,file));
1972
- DROP TABLE IF EXISTS review_viewed;
1973
2752
  CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
1974
2753
  CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
1975
- DROP TABLE IF EXISTS authoring_drafts;
2754
+ CREATE TABLE IF NOT EXISTS board_dependencies(review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
2755
+ depends_on TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, PRIMARY KEY(review_id,depends_on));
2756
+ CREATE INDEX IF NOT EXISTS board_dependencies_depends_on ON board_dependencies(depends_on);
2757
+ -- A link goes with the board it is on. Where it points stays plain
2758
+ -- text, no foreign key: a link to a deleted board stays, and reads
2759
+ -- "Board deleted" from the deleted_boards tombstone.
2760
+ CREATE TABLE IF NOT EXISTS board_links(from_review TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, from_block TEXT NOT NULL,
2761
+ to_review TEXT NOT NULL, to_block TEXT);
2762
+ CREATE INDEX IF NOT EXISTS board_links_from ON board_links(from_review);
2763
+ CREATE INDEX IF NOT EXISTS board_links_to ON board_links(to_review);
2764
+ CREATE TABLE IF NOT EXISTS deleted_boards(id TEXT PRIMARY KEY, title TEXT NOT NULL, deleted_at TEXT NOT NULL,
2765
+ project_id TEXT);
1976
2766
  `);
1977
- db.exec("BEGIN IMMEDIATE");
2767
+ }
2768
+ //#endregion
2769
+ //#region src/review-api/scratchpad-plan-upgrade.ts
2770
+ const OLD_SCRATCHPAD_ID = "scratchpad";
2771
+ const hasScratchpadVersions = ({ db }) => Boolean(db.prepare("SELECT 1 FROM versions WHERE review_id=? LIMIT 1").get(OLD_SCRATCHPAD_ID));
2772
+ /** A pad version's snapshot with its document in today's form (old pads
2773
+ * held anchors as objects); undefined for one that won't parse. */
2774
+ function parsePadVersion(text) {
1978
2775
  try {
1979
- if (db.prepare("PRAGMA table_info(review_attention)").all().some((column) => column.name === "dismissed_at")) {
2776
+ const snapshot = JSON.parse(text);
2777
+ const document = migrateStoredDocument(snapshot.document);
2778
+ snapshot.document = Array.isArray(document) ? document : [];
2779
+ return snapshot;
2780
+ } catch {
2781
+ return;
2782
+ }
2783
+ }
2784
+ /** Each pad version as parsed, by version, for one open. The run, inside
2785
+ * the write lock, reads the rows again: it reuses a parse only of text that
2786
+ * hasn't changed since. */
2787
+ const parsedPads = /* @__PURE__ */ new WeakMap();
2788
+ function padVersion(context, version, text) {
2789
+ let parsed = parsedPads.get(context);
2790
+ if (!parsed) parsedPads.set(context, parsed = /* @__PURE__ */ new Map());
2791
+ const known = parsed.get(version);
2792
+ if (known?.text === text) return known.snapshot;
2793
+ const snapshot = parsePadVersion(text);
2794
+ parsed.set(version, {
2795
+ text,
2796
+ snapshot
2797
+ });
2798
+ return snapshot;
2799
+ }
2800
+ /** The pad's versions, oldest first, each parsed once per open. */
2801
+ const scratchpadVersions = (context) => context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version").all(OLD_SCRATCHPAD_ID).map((row) => ({
2802
+ version: Number(row.version),
2803
+ snapshot: padVersion(context, Number(row.version), String(row.snapshot))
2804
+ }));
2805
+ /** The pad's newest version that parses, reading no further back. */
2806
+ function latestScratchpadDocument(context) {
2807
+ for (const row of context.db.prepare("SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version DESC").iterate(OLD_SCRATCHPAD_ID)) {
2808
+ const snapshot = padVersion(context, Number(row.version), String(row.snapshot));
2809
+ if (snapshot) return snapshot.document;
2810
+ }
2811
+ }
2812
+ /** The checkout the pad quotes most, else the one opened last. Only a
2813
+ * checkout that still exists: a plan opens on it. */
2814
+ function scratchpadRepository(context) {
2815
+ const exists = (repositoryId) => {
2816
+ try {
2817
+ return existsSync(context.repositoryPath(repositoryId));
2818
+ } catch {
2819
+ return false;
2820
+ }
2821
+ };
2822
+ const counts = /* @__PURE__ */ new Map();
2823
+ const latest = latestScratchpadDocument(context);
2824
+ for (const { source } of sourceReferences(latest ?? [], { tolerant: true })) {
2825
+ const repositoryId = source.pins?.repositoryId;
2826
+ if (repositoryId && exists(repositoryId)) counts.set(repositoryId, (counts.get(repositoryId) ?? 0) + 1);
2827
+ }
2828
+ const [quoted] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
2829
+ if (quoted) return quoted;
2830
+ return [...context.db.prepare(`SELECT ${BOARD_REPOSITORY_ID_SQL} AS repository
2831
+ FROM review_attention
2832
+ JOIN reviews ON reviews.id=review_attention.review_id
2833
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2834
+ WHERE review_attention.viewed_at IS NOT NULL AND repository IS NOT NULL
2835
+ ORDER BY review_attention.viewed_at DESC`).all().map(({ repository }) => String(repository)), ...context.repositories().map(({ id }) => id)].find(exists);
2836
+ }
2837
+ const snapshots = (context, own, document, allocate) => snapshotForeignReferences(document, own, context.fileAtCommit, context.repositoryName, allocate);
2838
+ /** Reads the files the pad quotes, which the context keeps, before the
2839
+ * write lock other hosts on this home wait on. */
2840
+ function readScratchpadQuotes(context) {
2841
+ if (!hasScratchpadVersions(context)) return;
2842
+ const quoting = scratchpadRepository(context);
2843
+ if (quoting) {
2844
+ for (const { snapshot } of scratchpadVersions(context)) if (snapshot) snapshots(context, quoting, snapshot.document, (prefix) => prefix);
2845
+ }
2846
+ }
2847
+ function planFromScratchpad(context) {
2848
+ const { db } = context;
2849
+ if (!hasScratchpadVersions(context)) return;
2850
+ const own = scratchpadRepository(context);
2851
+ const id = randomUUID();
2852
+ db.prepare("INSERT INTO reviews(id,version,next_id,project_id) SELECT ?,version,next_id,? FROM reviews WHERE id=?").run(id, own ? context.projectOfRepository(own) : context.fallbackProject(), OLD_SCRATCHPAD_ID);
2853
+ let nextId = Number(db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id);
2854
+ const old = scratchpadVersions(context);
2855
+ parsedPads.delete(context);
2856
+ db.prepare("UPDATE versions SET review_id=? WHERE review_id=?").run(id, OLD_SCRATCHPAD_ID);
2857
+ for (const { version, snapshot } of old) {
2858
+ if (!snapshot) continue;
2859
+ delete snapshot.kind;
2860
+ snapshot.reviewId = id;
2861
+ snapshot.title = "Scratchpad";
2862
+ if (own) {
2863
+ snapshot.target = {
2864
+ kind: "project",
2865
+ repositoryId: own
2866
+ };
2867
+ snapshot.document = snapshots(context, own, snapshot.document, (prefix) => `${prefix}-${++nextId}`);
2868
+ }
2869
+ context.rewriteVersion(id, version, snapshot);
2870
+ }
2871
+ db.prepare("UPDATE reviews SET next_id=? WHERE id=?").run(nextId, id);
2872
+ for (const [table, ...names] of [
2873
+ [
2874
+ "ask_conversations",
2875
+ "review_id",
2876
+ "anchor_board"
2877
+ ],
2878
+ ["authoring_presences", "review_id"],
2879
+ ["review_coverage", "review_id"],
2880
+ ["review_attention", "review_id"]
2881
+ ]) {
2882
+ const columns = columnsOf(db, table);
2883
+ const column = names.find((name) => columns.has(name));
2884
+ if (column) db.prepare(`UPDATE ${table} SET ${column}=? WHERE ${column}=?`).run(id, OLD_SCRATCHPAD_ID);
2885
+ }
2886
+ db.prepare("DELETE FROM reviews WHERE id=?").run(OLD_SCRATCHPAD_ID);
2887
+ }
2888
+ //#endregion
2889
+ //#region src/review-api/store-migrations.ts
2890
+ /** Keep versions.author_kind to its values, as a CHECK would. */
2891
+ const VERSION_AUTHOR_TRIGGERS = `
2892
+ CREATE TRIGGER IF NOT EXISTS versions_author_kind_insert BEFORE INSERT ON versions
2893
+ WHEN NEW.author_kind NOT IN ('person','agent','system')
2894
+ BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;
2895
+ CREATE TRIGGER IF NOT EXISTS versions_author_kind_update BEFORE UPDATE OF author_kind ON versions
2896
+ WHEN NEW.author_kind NOT IN ('person','agent','system')
2897
+ BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;`;
2898
+ const STORE_UPGRADES = [
2899
+ {
2900
+ version: 1,
2901
+ name: "retired-tables",
2902
+ run: ({ db }) => db.exec(`DROP TABLE IF EXISTS receipts;
2903
+ DROP TABLE IF EXISTS review_viewed;
2904
+ DROP TABLE IF EXISTS authoring_drafts;
2905
+ DROP TABLE IF EXISTS authoring_sessions;`)
2906
+ },
2907
+ {
2908
+ version: 2,
2909
+ name: "archive-to-trash",
2910
+ run: ({ db }) => {
2911
+ if (!columnsOf(db, "review_attention").has("dismissed_at")) return;
1980
2912
  db.exec("ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at");
1981
2913
  db.prepare("UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL").run((/* @__PURE__ */ new Date()).toISOString());
1982
2914
  }
1983
- db.exec("COMMIT");
1984
- } catch (error) {
1985
- db.exec("ROLLBACK");
1986
- throw error;
2915
+ },
2916
+ {
2917
+ version: 3,
2918
+ name: "board-projects",
2919
+ groupsCheckouts: true,
2920
+ foreignKeysOff: true,
2921
+ run: ({ db, repositories, projectOfRepository, firstRepositoryProject }) => {
2922
+ const parents = () => db.exec("CREATE INDEX IF NOT EXISTS reviews_parent ON reviews(parent_id)");
2923
+ if (columnsOf(db, "reviews").has("project_id")) return parents();
2924
+ if (!columnsOf(db, "repositories").has("project_id")) db.exec("ALTER TABLE repositories ADD COLUMN project_id TEXT REFERENCES projects(id)");
2925
+ const boards = db.prepare(`SELECT reviews.id, CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END AS repository_id,
2926
+ review_attention.viewed_at
2927
+ FROM reviews LEFT JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2928
+ LEFT JOIN review_attention ON review_attention.review_id=reviews.id
2929
+ ORDER BY reviews.rowid`).all();
2930
+ const registered = new Set(repositories().map(({ id }) => id));
2931
+ const projects = /* @__PURE__ */ new Map();
2932
+ for (const { id, repository_id: repository } of boards) if (repository && registered.has(String(repository))) projects.set(String(id), projectOfRepository(String(repository)));
2933
+ const [opened] = boards.filter(({ id, viewed_at }) => viewed_at && projects.has(String(id))).sort((a, b) => String(b.viewed_at).localeCompare(String(a.viewed_at)));
2934
+ let fallback = opened && projects.get(String(opened.id));
2935
+ const brokenReferences = () => db.prepare("PRAGMA foreign_key_check").all().filter(({ parent }) => parent === "reviews").length;
2936
+ const broken = brokenReferences();
2937
+ db.exec(`CREATE TABLE reviews_with_projects(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
2938
+ project_id TEXT NOT NULL REFERENCES projects(id),
2939
+ parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL)`);
2940
+ const insert = db.prepare("INSERT INTO reviews_with_projects(id,version,next_id,project_id) SELECT id,version,next_id,? FROM reviews WHERE id=?");
2941
+ for (const { id } of boards) insert.run(projects.get(String(id)) ?? (fallback ??= firstRepositoryProject()), String(id));
2942
+ db.exec(`DROP TABLE reviews;
2943
+ ALTER TABLE reviews_with_projects RENAME TO reviews;`);
2944
+ parents();
2945
+ if (brokenReferences() > broken) throw new Error("Giving boards projects broke a reference.");
2946
+ }
2947
+ },
2948
+ {
2949
+ version: 4,
2950
+ name: "scratchpad-plan",
2951
+ beforeLock: readScratchpadQuotes,
2952
+ run: planFromScratchpad
2953
+ },
2954
+ {
2955
+ version: 5,
2956
+ name: "ask-threads-by-project",
2957
+ run: ({ db }) => {
2958
+ db.exec("CREATE INDEX IF NOT EXISTS reviews_project ON reviews(project_id)");
2959
+ const columns = columnsOf(db, "ask_conversations");
2960
+ if (columns.size === 0) return;
2961
+ if (columns.has("review_id")) db.exec("ALTER TABLE ask_conversations RENAME COLUMN review_id TO anchor_board");
2962
+ if (!columns.has("anchor_block")) db.exec("ALTER TABLE ask_conversations ADD COLUMN anchor_block TEXT");
2963
+ }
2964
+ },
2965
+ {
2966
+ version: 6,
2967
+ name: "version-threads",
2968
+ run: ({ db }) => {
2969
+ if (!columnsOf(db, "versions").has("thread_id")) db.exec("ALTER TABLE versions ADD COLUMN thread_id TEXT");
2970
+ db.exec("CREATE INDEX IF NOT EXISTS versions_thread_id ON versions(thread_id)");
2971
+ }
2972
+ },
2973
+ {
2974
+ version: 7,
2975
+ name: "viewed-on-upgrade",
2976
+ run: ({ db }) => db.exec(`INSERT OR IGNORE INTO review_attention(review_id) SELECT id FROM reviews;
2977
+ UPDATE review_attention SET viewed_at=COALESCE(NULLIF((
2978
+ SELECT MAX(COALESCE(review_attention.viewed_at, ''), COALESCE(CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.createdAt') END, ''))
2979
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2980
+ WHERE reviews.id=review_attention.review_id), ''), review_attention.viewed_at)
2981
+ WHERE review_id IN (SELECT id FROM reviews);`)
2982
+ },
2983
+ {
2984
+ version: 8,
2985
+ name: "ask-thread-viewed",
2986
+ run: ({ db }) => {
2987
+ const columns = columnsOf(db, "ask_conversations");
2988
+ if (columns.size > 0 && !columns.has("viewed_at")) db.exec("ALTER TABLE ask_conversations ADD COLUMN viewed_at TEXT");
2989
+ }
2990
+ },
2991
+ {
2992
+ version: 9,
2993
+ name: "version-authors",
2994
+ run: ({ db }) => {
2995
+ if (!columnsOf(db, "versions").has("author_kind")) db.exec("ALTER TABLE versions ADD COLUMN author_kind TEXT");
2996
+ db.exec(VERSION_AUTHOR_TRIGGERS);
2997
+ }
2998
+ },
2999
+ {
3000
+ version: 10,
3001
+ name: "ask-permissions",
3002
+ run: ({ db }) => {
3003
+ const columns = columnsOf(db, "ask_conversations");
3004
+ if (columns.size > 0 && !columns.has("permissions")) db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
3005
+ }
3006
+ }
3007
+ ];
3008
+ STORE_UPGRADES.at(-1).version;
3009
+ const storeVersion = (db) => Number(db.prepare("PRAGMA user_version").get().user_version);
3010
+ /** Runs every step the store hasn't, each in its own transaction with the
3011
+ * version it reaches, so a failed step rolls back alone and stops the store
3012
+ * opening, naming the step; the ones before it stay done. A store at a
3013
+ * later version than this build knows is left as it is. */
3014
+ function upgradeStore(context) {
3015
+ const { db } = context;
3016
+ for (const step of STORE_UPGRADES) {
3017
+ if (storeVersion(db) >= step.version) continue;
3018
+ if (step.groupsCheckouts) context.prepareCheckouts();
3019
+ step.beforeLock?.(context);
3020
+ if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=OFF");
3021
+ db.exec("BEGIN IMMEDIATE");
3022
+ try {
3023
+ if (storeVersion(db) < step.version) {
3024
+ step.run(context);
3025
+ db.exec(`PRAGMA user_version=${step.version}`);
3026
+ }
3027
+ db.exec("COMMIT");
3028
+ } catch (error) {
3029
+ if (db.isTransaction) db.exec("ROLLBACK");
3030
+ throw new Error(`Could not upgrade the whiteboard store: step ${step.version} (${step.name}) failed and was rolled back. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3031
+ } finally {
3032
+ if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=ON");
3033
+ }
3034
+ }
3035
+ }
3036
+ /** How the store's checkouts group, worked out before it opens, asking
3037
+ * several repositories at once, when an upgrade still to run needs it;
3038
+ * undefined otherwise, or when the store can't be read so, and the step
3039
+ * works it out itself. */
3040
+ async function groupCheckoutsAhead(databasePath) {
3041
+ if (!existsSync(databasePath)) return void 0;
3042
+ let db;
3043
+ try {
3044
+ db = new DatabaseSync(databasePath, { timeout: 5e3 });
3045
+ const version = storeVersion(db);
3046
+ if (!STORE_UPGRADES.some((step) => step.groupsCheckouts && step.version > version) || !columnsOf(db, "repositories").size) return void 0;
3047
+ const groups = new CheckoutGroups(db);
3048
+ await groups.prepareConcurrently();
3049
+ return groups.prepared;
3050
+ } catch {
3051
+ return;
3052
+ } finally {
3053
+ db?.close();
1987
3054
  }
1988
3055
  }
1989
3056
  //#endregion
@@ -1991,17 +3058,11 @@ function initializeReviewStoreSchema(db) {
1991
3058
  const reviewId = z.string().min(1);
1992
3059
  /** The key older hosts read and write. */
1993
3060
  const statsKey = (pins) => JSON.stringify([pins, "structural"]);
1994
- /** There is one scratchpad. Its id is fixed so a skill can name it. */
1995
- const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
1996
- const DIAGRAM_TYPES = /* @__PURE__ */ new Set([
1997
- "sequence",
1998
- "flow_diagram",
1999
- "call_stack_diff",
2000
- "database_lens",
2001
- "software_map"
2002
- ]);
2003
- const SCRATCHPAD_TITLE = "Scratchpad";
3061
+ /** How long a board stays in Trash before it is deleted. */
3062
+ const TRASH_DAYS = 30;
2004
3063
  const activityId = z.string().min(1).optional().describe("The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.");
3064
+ const parentId = reviewId.describe("The reviewId of a top-level review in the same project to nest this one under.");
3065
+ const dependsOn = z.array(reviewId).describe("The reviewIds of reviews in the same project this one builds on. Siblings list after what they depend on.");
2005
3066
  const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
2006
3067
  z.strictObject({
2007
3068
  type: z.literal("delete"),
@@ -2019,16 +3080,28 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
2019
3080
  }),
2020
3081
  z.strictObject({
2021
3082
  type: z.literal("create"),
2022
- /** Required unless pullRequestUrl alone names the source; then the PR title. */
2023
- title: z.string().trim().min(1).optional(),
3083
+ /** Required unless pullRequestUrl alone names the source; then the PR
3084
+ * title. A person's new board may start with none; it reads
3085
+ * "Untitled" until they name it. */
3086
+ title: z.string().trim().optional(),
2024
3087
  target: reviewTargetSchema.optional(),
2025
3088
  pullRequestUrl: pullRequestUrl.optional(),
2026
3089
  /** With pullRequestUrl and no target: the checkout to fetch the PR into. */
2027
- 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."),
3090
+ repositoryId: z.string().min(1).optional().describe("Only with pullRequestUrl and no target: the registered checkout to fetch the PR into."),
2028
3091
  /** Return the existing review for pullRequestUrl instead of creating one. */
2029
3092
  reuseExisting: z.boolean().optional().describe("Default true: return the existing review for pullRequestUrl. false creates a separate review."),
2030
- /** The one scratchpad: no target, no pins; every reference names its own. */
2031
- kind: z.literal("scratchpad").optional()
3093
+ parentId: parentId.optional(),
3094
+ dependsOn: dependsOn.optional()
3095
+ }),
3096
+ z.strictObject({
3097
+ type: z.literal("set_parent"),
3098
+ reviewId,
3099
+ parentId: parentId.nullable()
3100
+ }),
3101
+ z.strictObject({
3102
+ type: z.literal("set_dependencies"),
3103
+ reviewId,
3104
+ dependsOn
2032
3105
  }),
2033
3106
  z.strictObject({
2034
3107
  type: z.literal("set_target"),
@@ -2059,12 +3132,60 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
2059
3132
  version: z.number().int().nonnegative()
2060
3133
  })
2061
3134
  ]) });
3135
+ /** A person's save of a whole board from the canvas. A block they added
3136
+ * has no id and may carry a clientKey, which the response maps to its id. */
3137
+ const personEditSchema = z.strictObject({
3138
+ baseVersion: z.number().int().nonnegative(),
3139
+ /** Empty until the person names the board; it reads "Untitled". */
3140
+ title: z.string().trim(),
3141
+ document: z.array(z.json())
3142
+ });
3143
+ /** The block a person's canvas has focus in; null clears it. */
3144
+ const focusLeaseSchema = z.strictObject({
3145
+ clientId: z.string().min(1),
3146
+ blockId: z.string().min(1).nullable(),
3147
+ /** false when the canvas closes the board. */
3148
+ open: z.boolean().optional()
3149
+ });
3150
+ /** A focus lease lasts this long unless renewed. The canvas renews it every
3151
+ * few seconds, so a closed or crashed app releases its block on its own. */
3152
+ const FOCUS_LEASE_TTL_MS = 15e3;
3153
+ /** How often a discard looks again for a canvas letting go of the board. */
3154
+ const DISCARD_POLL_MS = 100;
3155
+ /**
3156
+ * The project of a registered checkout, made on first use. Checkouts of one
3157
+ * repository (worktrees, or clones of one GitHub repository) share it. A
3158
+ * deleted checkout joins the project of the existing one it belonged to
3159
+ * (see `CheckoutGroups.ownerOf`), else gets its own.
3160
+ */
3161
+ function projectOfCheckout(db, repositoryId, groups = new CheckoutGroups(db)) {
3162
+ const { path: root, name, project_id: projectOf } = db.prepare("SELECT path, name, project_id FROM repositories WHERE id=?").get(repositoryId);
3163
+ if (projectOf) return String(projectOf);
3164
+ const join = (projectId) => {
3165
+ db.prepare("UPDATE repositories SET project_id=? WHERE id=?").run(projectId, repositoryId);
3166
+ return projectId;
3167
+ };
3168
+ if (!existsSync(String(root))) {
3169
+ const owner = groups.ownerOf(repositoryId);
3170
+ if (owner) return join(projectOfCheckout(db, owner, groups));
3171
+ }
3172
+ const group = groups.group(String(root));
3173
+ const sibling = group && db.prepare("SELECT project_id, path FROM repositories WHERE project_id IS NOT NULL").all().find(({ path: checkout }) => groups.group(String(checkout))?.key === group.key);
3174
+ if (sibling) return join(String(sibling.project_id));
3175
+ const projectId = randomUUID();
3176
+ db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, group?.label ?? String(name));
3177
+ return join(projectId);
3178
+ }
3179
+ /** The Ask thread a version names, on its edit or on itself; stored in
3180
+ * `versions.thread_id` so a thread's boards are one index away. */
3181
+ const versionThread = (snapshot) => snapshot.lastEdit?.threadId ?? snapshot.threadId ?? null;
2062
3182
  /** One instance owned by the desktop server. All writers go through execute().
2063
3183
  * The queue includes async validation; SQLite transactions contain only writes.
2064
3184
  * This prototype uses a new, explicitly supplied database, never an existing profile.
2065
3185
  */
2066
3186
  var ReviewStore = class {
2067
3187
  providers;
3188
+ checkoutOwners;
2068
3189
  activity;
2069
3190
  askHistory;
2070
3191
  db;
@@ -2212,24 +3333,40 @@ var ReviewStore = class {
2212
3333
  this.catalogListeners.delete(listener);
2213
3334
  };
2214
3335
  }
2215
- /** For a host whose listing changed without the store: a preference flip. */
2216
- invalidateCatalog() {
2217
- for (const listener of this.catalogListeners) try {
2218
- listener();
2219
- } catch {}
2220
- }
2221
3336
  subscribe(listener) {
2222
3337
  this.listeners.add(listener);
2223
3338
  return () => {
2224
3339
  this.listeners.delete(listener);
2225
3340
  };
2226
3341
  }
2227
- constructor(databasePath, providers) {
3342
+ constructor(databasePath, providers, checkoutOwners) {
2228
3343
  this.providers = providers;
3344
+ this.checkoutOwners = checkoutOwners;
2229
3345
  this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
2230
3346
  initializeReviewStoreSchema(this.db);
3347
+ const files = /* @__PURE__ */ new Map();
3348
+ upgradeStore({
3349
+ db: this.db,
3350
+ prepareCheckouts: () => this.checkouts.prepare(),
3351
+ repositories: () => this.repositories(),
3352
+ repositoryPath: (repositoryId) => this.repositoryPath(repositoryId),
3353
+ repositoryName: (repositoryId) => this.repositoryName(repositoryId),
3354
+ fileAtCommit: (repositoryId, commit, file) => {
3355
+ const key = JSON.stringify([
3356
+ repositoryId,
3357
+ commit,
3358
+ file
3359
+ ]);
3360
+ if (!files.has(key)) files.set(key, this.fileAtCommit(repositoryId, commit, file));
3361
+ return files.get(key);
3362
+ },
3363
+ projectOfRepository: (repositoryId) => this.projectOfRepository(repositoryId),
3364
+ fallbackProject: () => this.fallbackProject(),
3365
+ rewriteVersion: (reviewId, version, snapshot) => this.rewriteVersion(reviewId, version, snapshot),
3366
+ firstRepositoryProject: () => this.firstRepositoryProject()
3367
+ });
2231
3368
  this.activity = new ReviewActivity(this.db, (id) => this.assertWritable(id));
2232
- this.askHistory = new AskHistory(this.db);
3369
+ this.askHistory = new AskHistory(this.db, (id) => this.assertOutOfTrash(id));
2233
3370
  this.purgeExpired();
2234
3371
  this.purgeTimer = setInterval(() => this.purgeExpired(), 6e4);
2235
3372
  this.purgeTimer.unref();
@@ -2239,6 +3376,75 @@ var ReviewStore = class {
2239
3376
  this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
2240
3377
  this.externalChanges.unref();
2241
3378
  }
3379
+ /** Saves a version over what it was; the current one re-indexes its
3380
+ * board's links, as every written version does. */
3381
+ rewriteVersion(reviewId, version, snapshot) {
3382
+ this.db.prepare("UPDATE versions SET snapshot=? WHERE review_id=? AND version=?").run(JSON.stringify(snapshot), reviewId, version);
3383
+ if (version !== this.currentVersion(reviewId)) return;
3384
+ try {
3385
+ this.indexBoardLinks(reviewId, this.read(reviewId).document);
3386
+ } catch {}
3387
+ }
3388
+ /** The project of a registered checkout, made on first use. Checkouts of
3389
+ * one repository (worktrees, or clones of one GitHub repository) share it. */
3390
+ projectOfRepository(repositoryId) {
3391
+ const row = this.db.prepare("SELECT project_id FROM repositories WHERE id=?").get(repositoryId);
3392
+ if (!row) return this.fallbackProject();
3393
+ if (row.project_id) return String(row.project_id);
3394
+ return projectOfCheckout(this.db, repositoryId, this.checkouts);
3395
+ }
3396
+ /** For a board with no repository: the project of the board opened last,
3397
+ * else of the first registered checkout, else one with no repository. */
3398
+ fallbackProject() {
3399
+ const recent = this.db.prepare(`SELECT reviews.project_id FROM reviews
3400
+ JOIN review_attention ON review_attention.review_id=reviews.id
3401
+ WHERE review_attention.viewed_at IS NOT NULL
3402
+ ORDER BY review_attention.viewed_at DESC LIMIT 1`).get();
3403
+ return recent ? String(recent.project_id) : this.firstRepositoryProject();
3404
+ }
3405
+ firstRepositoryProject() {
3406
+ const [first] = this.repositories();
3407
+ if (first) return this.projectOfRepository(first.id);
3408
+ const empty = this.db.prepare("SELECT id FROM projects WHERE id NOT IN (SELECT project_id FROM repositories WHERE project_id IS NOT NULL) LIMIT 1").get();
3409
+ if (empty) return String(empty.id);
3410
+ const projectId = randomUUID();
3411
+ this.db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(projectId, "Whiteboards");
3412
+ return projectId;
3413
+ }
3414
+ /** A checkout's project, as the catalog lists it; undefined once the
3415
+ * checkout is unregistered. */
3416
+ checkoutProject(repositoryId) {
3417
+ if (!this.repositories().some(({ id }) => id === repositoryId)) return void 0;
3418
+ const id = this.projectOfRepository(repositoryId);
3419
+ const { name } = this.db.prepare("SELECT name FROM projects WHERE id=?").get(id);
3420
+ return {
3421
+ id,
3422
+ name: String(name)
3423
+ };
3424
+ }
3425
+ /** Undefined when the file can't be read. */
3426
+ fileAtCommit(repositoryId, commit, file) {
3427
+ try {
3428
+ return this.checkouts.fileAt(this.repositoryPath(repositoryId), commit, file);
3429
+ } catch {
3430
+ return;
3431
+ }
3432
+ }
3433
+ /** A checkout's name as its project labels it, else its folder. */
3434
+ repositoryName(repositoryId) {
3435
+ try {
3436
+ const root = this.repositoryPath(repositoryId);
3437
+ return this.repositoryGroup(root)?.label.split("/").at(-1) ?? path.basename(root);
3438
+ } catch {
3439
+ return "another repository";
3440
+ }
3441
+ }
3442
+ /** A board's project. */
3443
+ projectOf(id) {
3444
+ const row = this.db.prepare("SELECT project_id FROM reviews WHERE id=?").get(id);
3445
+ if (!row) this.assertExists(id);
3446
+ return String(row.project_id);
3447
+ }
2242
3448
  dataVersion() {
2243
3449
  return Number(this.db.prepare("PRAGMA data_version").get().data_version);
2244
3450
  }
@@ -2306,34 +3512,56 @@ var ReviewStore = class {
2306
3512
  throw error;
2307
3513
  }
2308
3514
  }
2309
- repositoryGroups = /* @__PURE__ */ new Map();
3515
+ checkoutGroups;
3516
+ /** How the store's checkouts group into projects, kept for the open. */
3517
+ get checkouts() {
3518
+ return this.checkoutGroups ??= new CheckoutGroups(this.db, void 0, this.checkoutOwners);
3519
+ }
2310
3520
  repositoryGroup(root) {
2311
- if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
2312
- if (!existsSync(root)) return void 0;
2313
- const context = resolveRepoContextSync(root);
2314
- if (!context) return void 0;
2315
- const group = context.githubSlug ? {
2316
- key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
2317
- label: context.githubSlug
2318
- } : {
2319
- key: `git:${context.commonDir}`,
2320
- label: path.basename(path.dirname(context.commonDir))
2321
- };
2322
- this.repositoryGroups.set(root, group);
2323
- return group;
3521
+ return this.checkouts.group(root);
2324
3522
  }
2325
3523
  registerRepository(root) {
2326
3524
  this.db.prepare("INSERT OR IGNORE INTO repositories(id,path,name) VALUES(?,?,?)").run(randomUUID(), root, root.split(/[\\/]/).at(-1));
2327
3525
  const row = this.db.prepare("SELECT id,name FROM repositories WHERE path=?").get(root);
3526
+ this.projectOfRepository(String(row.id));
2328
3527
  return {
2329
3528
  id: String(row.id),
2330
3529
  name: String(row.name)
2331
3530
  };
2332
3531
  }
3532
+ /** Forgets a checkout no board uses, after every write queued before it,
3533
+ * so a board being made with it keeps it. */
2333
3534
  unregisterRepository(id) {
2334
- this.db.prepare(`DELETE FROM repositories WHERE id=?
2335
- AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
2336
- AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`).run(id, `"repositoryId":${JSON.stringify(id)}`, id);
3535
+ const run = this.pending.then(async () => {
3536
+ this.db.exec("BEGIN IMMEDIATE");
3537
+ try {
3538
+ if (!this.db.prepare(`SELECT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
3539
+ OR EXISTS (SELECT 1 FROM resources WHERE repository_id=?) AS used`).get(`"repositoryId":${JSON.stringify(id)}`, id).used) this.db.prepare("DELETE FROM repositories WHERE id=?").run(id);
3540
+ this.db.exec("COMMIT");
3541
+ } catch (error) {
3542
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
3543
+ throw error;
3544
+ }
3545
+ });
3546
+ this.pending = run.catch(() => {});
3547
+ return run;
3548
+ }
3549
+ /** Checkouts whose remote is cloneUrl, most recently used first. */
3550
+ checkoutsOf(cloneUrl) {
3551
+ const key = `remote:${cloneUrl.toLowerCase()}`;
3552
+ const used = (repositoryId) => String(this.db.prepare(`SELECT MAX(json_extract(versions.snapshot,'$.createdAt')) AS at
3553
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
3554
+ WHERE json_extract(versions.snapshot,'$.target.repositoryId')=?`).get(repositoryId)?.at ?? "");
3555
+ return this.repositories().filter((repository) => {
3556
+ try {
3557
+ return this.repositoryGroup(repository.path)?.key === key;
3558
+ } catch {
3559
+ return false;
3560
+ }
3561
+ }).map((repository) => ({
3562
+ id: repository.id,
3563
+ at: used(repository.id)
3564
+ })).sort((left, right) => right.at.localeCompare(left.at)).map((repository) => repository.id);
2337
3565
  }
2338
3566
  /** Registered checkouts, oldest registration first. */
2339
3567
  repositories() {
@@ -2398,8 +3626,14 @@ var ReviewStore = class {
2398
3626
  assertExists(id) {
2399
3627
  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);
2400
3628
  }
3629
+ /** A board that exists and isn't in Trash: the one rule every write
3630
+ * about a board keeps. */
2401
3631
  assertWritable(id) {
2402
3632
  this.assertExists(id);
3633
+ this.assertOutOfTrash(id);
3634
+ }
3635
+ /** Refuses a board in Trash, which is read-only until it is restored. */
3636
+ assertOutOfTrash(id) {
2403
3637
  const trashedAt = this.trashTime(id);
2404
3638
  if (trashedAt) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", {
2405
3639
  month: "short",
@@ -2410,40 +3644,28 @@ var ReviewStore = class {
2410
3644
  const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(id);
2411
3645
  return row?.deleted_at ? String(row.deleted_at) : null;
2412
3646
  }
3647
+ /** Removes a board for good, from Delete… or Trash's purge. Links into
3648
+ * it stay, and show it as deleted. */
2413
3649
  removePermanently(id) {
3650
+ const { title } = this.read(id);
2414
3651
  for (const table of [
2415
- "ask_conversations",
2416
3652
  "authoring_presences",
2417
3653
  "review_coverage",
2418
3654
  "review_attention",
2419
3655
  "versions"
2420
3656
  ]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
3657
+ this.askHistory.removeReview(id);
3658
+ this.db.prepare("INSERT OR REPLACE INTO deleted_boards(id,title,deleted_at,project_id) VALUES(?,?,?,(SELECT project_id FROM reviews WHERE id=?))").run(id, title, (/* @__PURE__ */ new Date()).toISOString(), id);
2421
3659
  this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
2422
3660
  this.liveSources.delete(id);
2423
3661
  }
3662
+ /** Deletes the boards 30 days in Trash, each as Delete… would. */
2424
3663
  purgeExpired() {
2425
- const cutoff = (/* @__PURE__ */ new Date(Date.now() - 2592e6)).toISOString();
2426
- const expired = [];
2427
- this.db.exec("BEGIN IMMEDIATE");
2428
- try {
2429
- for (const row of this.db.prepare("SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?").all(cutoff)) {
2430
- const reviewId = String(row.review_id);
2431
- expired.push({
2432
- reviewId,
2433
- version: Number(row.version),
2434
- deleted: true
2435
- });
2436
- this.removePermanently(reviewId);
2437
- }
2438
- this.db.exec("COMMIT");
2439
- } catch (error) {
2440
- this.db.exec("ROLLBACK");
2441
- throw error;
2442
- }
2443
- for (const result of expired) {
2444
- this.activity.deleted(result.reviewId);
2445
- this.notify(result);
2446
- }
3664
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - TRASH_DAYS * 864e5)).toISOString();
3665
+ const expired = () => this.db.prepare("SELECT review_id FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?").all(cutoff).map((row) => String(row.review_id));
3666
+ for (const reviewId of expired()) this.deleteBoard(this.read(reviewId), { kind: "person" }, { guard: () => {
3667
+ if (!expired().includes(reviewId)) throw new ReviewInputError("Restored from Trash.", 409);
3668
+ } });
2447
3669
  }
2448
3670
  read(id, version) {
2449
3671
  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);
@@ -2452,7 +3674,7 @@ var ReviewStore = class {
2452
3674
  throw new ReviewInputError("Review version not found.", 404);
2453
3675
  }
2454
3676
  const snapshot = JSON.parse(String(row.snapshot));
2455
- const { document, lenses } = liftFileLenses(migrateStoredDocument(snapshot.document));
3677
+ const { document, lenses } = readStoredDocument(snapshot.document);
2456
3678
  snapshot.document = document;
2457
3679
  if (snapshot.lenses) snapshot.lenses = migrateStoredLenses(snapshot.lenses);
2458
3680
  if (lenses.length) snapshot.lenses = [...snapshot.lenses ?? [], ...lenses];
@@ -2477,9 +3699,21 @@ var ReviewStore = class {
2477
3699
  listener();
2478
3700
  } catch {}
2479
3701
  }
2480
- /** Reviews of the tutorial earlier versions shipped, for their removal. */
2481
- legacyTutorialIds() {
2482
- 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));
3702
+ /** Deletes the tutorial earlier versions shipped. Like any board, each
3703
+ * goes to Trash first, then is deleted from there. */
3704
+ async removeLegacyTutorials() {
3705
+ const tutorials = 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));
3706
+ for (const reviewId of tutorials) {
3707
+ if (!this.trashTime(reviewId)) await this.execute({ operation: {
3708
+ type: "lifecycle",
3709
+ reviewId,
3710
+ state: "trash"
3711
+ } });
3712
+ await this.execute({ operation: {
3713
+ type: "delete",
3714
+ reviewId
3715
+ } });
3716
+ }
2483
3717
  }
2484
3718
  list() {
2485
3719
  return this.summaries();
@@ -2491,11 +3725,13 @@ var ReviewStore = class {
2491
3725
  summaries(id) {
2492
3726
  const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
2493
3727
  (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,
2494
- review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
3728
+ review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path,
3729
+ projects.id AS project_id, projects.name AS project_name
2495
3730
  FROM reviews
3731
+ JOIN projects ON projects.id=reviews.project_id
2496
3732
  JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2497
3733
  LEFT JOIN review_attention ON review_attention.review_id=reviews.id
2498
- LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
3734
+ LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
2499
3735
  ${id === void 0 ? "" : "WHERE reviews.id=?"}
2500
3736
  ORDER BY reviews.rowid`).all(...id === void 0 ? [] : [id]).map((row) => {
2501
3737
  const summary = JSON.parse(String(row.summary));
@@ -2507,19 +3743,30 @@ var ReviewStore = class {
2507
3743
  };
2508
3744
  const live = this.liveSources.get(summary.reviewId);
2509
3745
  if (live?.version === summary.version && summary.target?.kind === "worktree") summary.pins = live.pins;
2510
- const listed = {
3746
+ return {
2511
3747
  ...summary,
2512
3748
  firstCreatedAt: row.first_created_at ? String(row.first_created_at) : void 0,
2513
3749
  repositoryPath: row.repository_path ? String(row.repository_path) : void 0,
2514
- repositoryGroup: row.repository_path ? this.repositoryGroup(String(row.repository_path)) : void 0,
2515
- repositoryName: row.repository_name ? String(row.repository_name) : summary.pins?.repositoryId ?? "",
3750
+ project: {
3751
+ id: String(row.project_id),
3752
+ name: String(row.project_name)
3753
+ },
3754
+ repositoryName: row.repository_name ? String(row.repository_name) : "(unknown repository)",
2516
3755
  viewedAt: row.viewed_at ? String(row.viewed_at) : null,
2517
3756
  trashedAt: row.deleted_at ? String(row.deleted_at) : null,
3757
+ changed: !row.viewed_at || summary.createdAt > String(row.viewed_at),
2518
3758
  working: this.activity.isWorking(summary.reviewId)
2519
3759
  };
2520
- if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
2521
- return listed;
2522
3760
  });
3761
+ const graph = this.relationGraph();
3762
+ for (const listed of reviews) {
3763
+ const parentId = graph.parents.get(listed.reviewId);
3764
+ const children = graph.children.get(listed.reviewId);
3765
+ const dependsOn = graph.dependsOn.get(listed.reviewId);
3766
+ if (parentId) listed.parentId = parentId;
3767
+ if (children) listed.children = this.orderChildren(children, graph);
3768
+ if (dependsOn) listed.dependsOn = dependsOn;
3769
+ }
2523
3770
  return this.withDiffStats(reviews);
2524
3771
  }
2525
3772
  /** Local and imported summaries use the same persisted counts. */
@@ -2530,28 +3777,6 @@ var ReviewStore = class {
2530
3777
  diffStats: review.pins ? stats.get(statsKey(review.pins)) ?? null : null
2531
3778
  }));
2532
3779
  }
2533
- /** What the pad holds, for its Home card: blocks, and the diagrams among them. */
2534
- scratchpadContents() {
2535
- const blocks = elements(this.read(SCRATCHPAD_ID).document).filter((element) => !isUnit(element));
2536
- return {
2537
- blocks: blocks.length,
2538
- diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length
2539
- };
2540
- }
2541
- /** The one scratchpad, made on first use. A host that loses the race to
2542
- * another on the same home finds it made. */
2543
- async ensureScratchpad() {
2544
- if (this.has(SCRATCHPAD_ID)) return;
2545
- try {
2546
- await this.execute({ operation: {
2547
- type: "create",
2548
- title: SCRATCHPAD_TITLE,
2549
- kind: "scratchpad"
2550
- } });
2551
- } catch (error) {
2552
- if (!this.has(SCRATCHPAD_ID)) throw error;
2553
- }
2554
- }
2555
3780
  /** Two saved board/day pairs in the last fourteen days. */
2556
3781
  feedbackEligible(now = Date.now()) {
2557
3782
  const row = this.db.prepare(`SELECT count(*) AS pairs FROM (
@@ -2566,81 +3791,69 @@ var ReviewStore = class {
2566
3791
  return Number(row?.pairs) >= 2;
2567
3792
  }
2568
3793
  history(id) {
2569
- 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) => ({
2570
- version: Number(row.version),
2571
- title: String(row.title),
2572
- createdAt: String(row.created_at)
2573
- }));
3794
+ return this.db.prepare(`SELECT version,
3795
+ CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.title') END AS title,
3796
+ CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.createdAt') END AS created_at,
3797
+ author_kind,
3798
+ thread_id
3799
+ FROM versions WHERE review_id=? ORDER BY version`).all(id).map((row) => {
3800
+ const entry = {
3801
+ version: Number(row.version),
3802
+ title: String(row.title ?? ""),
3803
+ createdAt: String(row.created_at ?? "")
3804
+ };
3805
+ if (row.author_kind !== null) entry.author = row.author_kind === "person" ? "you" : "agent";
3806
+ if (row.author_kind !== "person" && row.thread_id !== null) entry.threadId = String(row.thread_id);
3807
+ return entry;
3808
+ });
2574
3809
  }
2575
3810
  inspect(id, targetId, version) {
2576
3811
  return inspectSnapshot(this.read(id, version), targetId);
2577
3812
  }
2578
3813
  /** The host can seed a document; transport callers only supply a command. */
2579
- execute(input, initial) {
3814
+ execute(input, initial, author = { kind: "agent" }) {
2580
3815
  if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
2581
3816
  const command = commandSchema.parse(input);
2582
3817
  if (initial && command.operation.type !== "create") throw new ReviewInputError("Initial content requires a create command.");
2583
3818
  const pullRequest = this.startPullRequest(command);
2584
3819
  pullRequest?.catch(() => {});
2585
3820
  const pullRequestTitle = pullRequest ? pullRequest.then(({ title }) => title || void 0, () => void 0) : this.startPullRequestTitle(command);
2586
- const run = this.pending.then(async () => {
3821
+ return this.enqueue(async () => {
2587
3822
  const op = command.operation;
2588
3823
  this.purgeExpired();
2589
- if (op.type !== "create" && op.type !== "delete" && op.type !== "lifecycle") this.assertWritable(op.reviewId);
2590
- 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);
2591
- if (op.type === "create" && op.kind === "scratchpad") {
2592
- if (op.target) throw new ReviewInputError("A scratchpad has no target of its own.");
2593
- if (this.has(SCRATCHPAD_ID)) throw new ReviewInputError("The scratchpad already exists.", 409);
2594
- } else if (op.type === "create") {
3824
+ if (op.type === "create") {
2595
3825
  if (!op.target && !op.pullRequestUrl) throw new ReviewInputError("Supply a target or a pullRequestUrl.");
2596
3826
  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.");
2597
- if (!op.title && op.target) throw new ReviewInputError("Supply a title.");
3827
+ if (!op.title && op.target && author.kind !== "person") throw new ReviewInputError("Supply a title.");
2598
3828
  }
2599
3829
  const requestedTarget = op.type === "create" || op.type === "set_target" ? op.target : void 0;
2600
3830
  const fromPullRequest = await pullRequest;
2601
- const resolvedTarget = requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
3831
+ const resolvedTarget = requestedTarget?.kind === "project" ? (this.repositoryPath(requestedTarget.repositoryId), { target: requestedTarget }) : requestedTarget ? await (this.providers.resolveTarget?.(requestedTarget) ?? namedCommits(requestedTarget)) : fromPullRequest;
2602
3832
  if (requestedTarget && !resolvedTarget) throw new ReviewInputError("Review targets are unavailable.");
2603
3833
  if (op.type === "create" && op.pullRequestUrl && op.reuseExisting !== false) {
2604
3834
  const [found, ...others] = this.reviewsForPullRequest(op.pullRequestUrl);
2605
- if (found) return this.existingReview(found, others, resolvedTarget.pins);
2606
- }
2607
- if (op.type === "delete") {
2608
- this.assertExists(op.reviewId);
2609
- if (!this.trashTime(op.reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
2610
- const result = {
2611
- reviewId: op.reviewId,
2612
- version: this.read(op.reviewId).version,
2613
- deleted: true
2614
- };
2615
- this.commitCommand(result, () => this.removePermanently(op.reviewId), () => this.assertMutation(op.reviewId, result.version));
2616
- return result;
2617
- }
2618
- if (op.type === "attention" || op.type === "lifecycle") {
2619
- const result = {
2620
- reviewId: op.reviewId,
2621
- version: this.read(op.reviewId).version,
2622
- attention: true,
2623
- ...op.type === "lifecycle" && { lifecycle: true }
2624
- };
2625
- this.commitCommand(result, () => {
2626
- this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(op.reviewId);
2627
- if (op.type === "attention") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
2628
- else {
2629
- const timestamp = op.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
2630
- this.db.prepare("UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?").run(timestamp, timestamp, op.reviewId);
2631
- }
2632
- });
2633
- return result;
3835
+ if (found) {
3836
+ const result = this.existingReview(found, others, resolvedTarget.pins);
3837
+ if (op.parentId || op.dependsOn) result.note += ` Its parent and dependencies were not changed either: set them with session_set_parent and session_set_dependencies.`;
3838
+ return result;
3839
+ }
2634
3840
  }
2635
- const id = op.type !== "create" ? op.reviewId : op.kind === "scratchpad" ? SCRATCHPAD_ID : randomUUID();
3841
+ if (op.type === "set_parent") return this.commitBoardFacts(op.reviewId, author, { parentId: op.parentId });
3842
+ if (op.type === "set_dependencies") return this.commitBoardFacts(op.reviewId, author, { dependsOn: op.dependsOn });
3843
+ if (op.type === "lifecycle") return this.commitBoardFacts(op.reviewId, author, { state: op.state });
3844
+ if (op.type === "attention") return this.commitBoardFacts(op.reviewId, author, { viewed: true });
3845
+ if (op.type === "delete") return this.deleteBoard(this.read(op.reviewId), author, { fromTrash: true });
3846
+ const id = op.type === "create" ? randomUUID() : op.reviewId;
2636
3847
  const previous = op.type === "create" ? void 0 : this.read(id);
2637
- let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`)) : structuredClone(previous);
3848
+ let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest && (fromPullRequest.title || `PR #${op.pullRequestUrl.split("/").at(-1)}`), author.kind === "person") : structuredClone(previous);
2638
3849
  delete snapshot.trashedAt;
2639
3850
  delete snapshot.sourceUnavailable;
2640
3851
  delete snapshot.lastEdit;
2641
3852
  let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
2642
3853
  let applied;
2643
3854
  let lensTarget;
3855
+ /** The head a worktree board read at, when it becomes a plan. */
3856
+ let unpinnedWorktree;
2644
3857
  if ((op.type === "create" || op.type === "set_target") && this.providers.headBranch) {
2645
3858
  const pins = resolvedTarget?.pins ?? snapshot.pins;
2646
3859
  if (pins) {
@@ -2657,14 +3870,18 @@ var ReviewStore = class {
2657
3870
  branch: fromPullRequest.branch ?? snapshot.origin?.branch,
2658
3871
  baseBranch: fromPullRequest.baseBranch
2659
3872
  };
2660
- if (requestedTarget) snapshot.origin = {
2661
- ...snapshot.origin,
2662
- baseBranch: requestedTarget.base === resolvedTarget?.pins.base ? void 0 : requestedTarget.base
2663
- };
3873
+ if (requestedTarget && requestedTarget.kind !== "project") {
3874
+ const named = requestedTarget.base;
3875
+ const base = resolvedTarget?.pins?.base;
3876
+ snapshot.origin = {
3877
+ ...snapshot.origin,
3878
+ baseBranch: named && !base?.startsWith(named.toLowerCase()) ? named : void 0
3879
+ };
3880
+ }
2664
3881
  switch (op.type) {
2665
3882
  case "create":
2666
3883
  if (initial) {
2667
- snapshot.document = documentSchema.parse(initial.document);
3884
+ snapshot.document = await this.resolveCommitLinks(documentSchema.parse(initial.document), snapshot);
2668
3885
  snapshot.origin = {
2669
3886
  ...snapshot.origin,
2670
3887
  ...structuredClone(initial.origin)
@@ -2677,17 +3894,22 @@ var ReviewStore = class {
2677
3894
  snapshot.title = op.title;
2678
3895
  break;
2679
3896
  case "set_target":
2680
- setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.pins?.repositoryId !== resolvedTarget.pins.repositoryId ? null : void 0), await pullRequestTitle);
3897
+ setPullRequest(snapshot, op.pullRequestUrl ?? (op.pullRequestUrl === null || snapshot.target?.repositoryId !== resolvedTarget.target.repositoryId ? null : void 0), await pullRequestTitle);
2681
3898
  snapshot.staleSources = [];
3899
+ if (snapshot.pins && !resolvedTarget.pins) {
3900
+ const pinned = pinDocumentReferences(snapshot.document, snapshot.pins);
3901
+ if (snapshot.target?.kind === "worktree" && JSON.stringify(pinned) !== JSON.stringify(snapshot.document)) unpinnedWorktree = snapshot.pins.head;
3902
+ snapshot.document = pinned;
3903
+ }
2682
3904
  snapshot.target = resolvedTarget.target;
2683
- snapshot.pins = resolvedTarget.pins;
3905
+ if (resolvedTarget.pins) snapshot.pins = resolvedTarget.pins;
3906
+ else delete snapshot.pins;
2684
3907
  break;
2685
3908
  case "restore":
2686
3909
  snapshot = this.read(id, op.version);
2687
3910
  delete snapshot.lastEdit;
2688
3911
  break;
2689
3912
  case "lens_edit": {
2690
- if (snapshot.kind === "scratchpad") throw new ReviewInputError("The scratchpad has no changes of its own to lens.", 409);
2691
3913
  const lenses = snapshot.lenses ?? [];
2692
3914
  const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
2693
3915
  if (lenses.length) snapshot.lenses = lenses;
@@ -2705,65 +3927,678 @@ var ReviewStore = class {
2705
3927
  };
2706
3928
  break;
2707
3929
  }
2708
- case "edit":
2709
- applied = applyEdit(snapshot.document, op.edit, (prefix) => `${prefix}-${++nextId}`, { placement: snapshot.kind === "scratchpad" ? "first" : "last" });
2710
- snapshot.lastEdit = summarizeEdit(op.edit, applied, previous.document, snapshot.document);
2711
- if (snapshot.staleSources?.length) {
2712
- const oldSources = new Map(sourceReferences(previous.document).map((item) => [item.id, JSON.stringify(item.source)]));
2713
- const newSources = new Map(sourceReferences(snapshot.document).map((item) => [item.id, JSON.stringify(item.source)]));
2714
- snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
2715
- }
2716
- }
2717
- if (snapshot.target?.kind === "worktree" && op.type !== "restore" && !resolvedTarget) snapshot = await this.projectLiveSource(snapshot);
2718
- checkReferences(snapshot.document);
2719
- if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
2720
- const advised = new Set(documentAdvice(previous?.document ?? []));
2721
- const warnings = [...documentAdvice(snapshot.document).filter((advice) => !advised.has(advice)), ...await this.validateExternal(snapshot, previous, op.type === "set_target")];
2722
- snapshot.version = previous ? previous.version + 1 : 0;
2723
- snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
2724
- const result = {
2725
- ...op.type === "create" && { created: true },
2726
- reviewId: id,
2727
- version: snapshot.version,
2728
- targetId: applied?.targetId ?? lensTarget?.targetId,
2729
- ...applied && { type: applied.type },
2730
- ...lensTarget && { type: lensTarget.type },
2731
- ...applied?.children && { children: applied.children }
2732
- };
2733
- if (warnings.length) result.warnings = warnings;
2734
- this.commitCommand(result, () => {
2735
- 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);
2736
- this.db.prepare("INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot));
2737
- }, previous ? () => {
2738
- this.assertWritable(id);
2739
- this.assertMutation(id, previous.version);
2740
- } : void 0, op.type === "edit" || op.type === "lens_edit" ? {
2741
- surface: op.type === "edit" ? "document" : "lenses",
2742
- activityId: op.activityId,
2743
- owned: (owner) => {
2744
- if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
3930
+ case "edit": {
3931
+ const edit = respellEditLinks(op.edit);
3932
+ applied = applyEdit(snapshot.document, edit, (prefix) => `${prefix}-${++nextId}`);
3933
+ snapshot.lastEdit = summarizeEdit(edit, applied, previous.document, snapshot.document);
3934
+ break;
2745
3935
  }
2746
- } : void 0);
2747
- return result;
3936
+ }
3937
+ const warnings = unpinnedWorktree ? [`Its references now read at ${unpinnedWorktree.slice(0, 7)}, the checkout's last commit, which lacks any uncommitted lines they quoted. Check them.`] : [];
3938
+ return this.commitVersion({
3939
+ snapshot,
3940
+ previous,
3941
+ nextId,
3942
+ author,
3943
+ warnings,
3944
+ restore: op.type === "restore",
3945
+ repin: op.type === "set_target",
3946
+ projectLive: op.type !== "restore" && !resolvedTarget,
3947
+ result: {
3948
+ ...op.type === "create" && { created: true },
3949
+ targetId: applied?.targetId ?? lensTarget?.targetId,
3950
+ ...applied && { type: applied.type },
3951
+ ...lensTarget && { type: lensTarget.type },
3952
+ ...applied?.children && { children: applied.children }
3953
+ },
3954
+ ...op.type === "create" && { relations: {
3955
+ parentId: op.parentId,
3956
+ dependsOn: op.dependsOn
3957
+ } },
3958
+ ...(op.type === "edit" || op.type === "lens_edit") && { courier: {
3959
+ surface: op.type === "edit" ? "document" : "lenses",
3960
+ activityId: op.activityId
3961
+ } }
3962
+ });
3963
+ });
3964
+ }
3965
+ /**
3966
+ * Commits a board's next version: the one place a version is written.
3967
+ * Whoever writes it, it keeps the same rules:
3968
+ * - an agent may not change a block a person holds;
3969
+ * - the version names its author: a thread's names the thread, a
3970
+ * person's is theirs and never inherits a thread;
3971
+ * - only a change of repository moves a board to another project, and an
3972
+ * existing board never takes the project of the board opened last;
3973
+ * - only links the change adds are checked;
3974
+ * - the version, its link index, the board's project and its relations
3975
+ * are written together, and only if the board is still at the version
3976
+ * read and the rules still hold.
3977
+ * Runs inside the write queue.
3978
+ */
3979
+ async commitVersion(change) {
3980
+ const { previous, author } = change;
3981
+ const id = change.snapshot.reviewId;
3982
+ let snapshot = change.snapshot;
3983
+ delete snapshot.threadId;
3984
+ delete snapshot.personEdit;
3985
+ if (snapshot.lastEdit) delete snapshot.lastEdit.threadId;
3986
+ if (previous && !change.restore) await this.normalizeDocument(snapshot, previous);
3987
+ if (change.unchangedSavesNothing && previous && snapshot.title === previous.title && isDeepStrictEqual(snapshot.document, previous.document)) return {
3988
+ reviewId: id,
3989
+ version: previous.version
3990
+ };
3991
+ const touched = previous ? changedBlocks(previous.document, snapshot.document) : [];
3992
+ const holds = () => {
3993
+ if (author.kind !== "person") this.assertUnleased(id, touched);
3994
+ };
3995
+ holds();
3996
+ if (author.kind === "person") snapshot.personEdit = {
3997
+ changed: touched,
3998
+ ...change.personIds && { ids: change.personIds }
3999
+ };
4000
+ else if (author.threadId) {
4001
+ if (snapshot.lastEdit) snapshot.lastEdit.threadId = author.threadId;
4002
+ else snapshot.threadId = author.threadId;
4003
+ }
4004
+ let warnings;
4005
+ ({snapshot, warnings} = await this.prepareVersion(snapshot, previous, {
4006
+ restore: change.restore,
4007
+ repin: change.repin,
4008
+ projectLive: change.projectLive,
4009
+ person: author.kind === "person"
4010
+ }));
4011
+ warnings = [...change.warnings ?? [], ...warnings];
4012
+ const result = {
4013
+ ...change.result,
4014
+ reviewId: id,
4015
+ version: snapshot.version
4016
+ };
4017
+ if (warnings.length) result.warnings = warnings;
4018
+ const projectId = this.projectFor(snapshot, previous);
4019
+ const previousProject = previous ? this.projectOf(id) : void 0;
4020
+ this.commitCommand(result, () => {
4021
+ this.saveVersion(snapshot, change.nextId, projectId, change.author);
4022
+ if (change.relations?.parentId) this.setParent(id, change.relations.parentId);
4023
+ if (change.relations?.dependsOn) this.setDependencies(id, change.relations.dependsOn);
4024
+ if (previous && projectId !== previousProject && this.dropRelationsOutside(id, projectId)) result.warnings = [...result.warnings ?? [], "Its parent, children and dependencies were in its old project, so they were dropped: whiteboards relate only within a project."];
4025
+ }, previous ? () => {
4026
+ this.assertWritable(id);
4027
+ if (change.baseVersion !== void 0) assertBaseVersion(change.baseVersion, this.currentVersion(id));
4028
+ else this.assertMutation(id, previous.version);
4029
+ holds();
4030
+ } : void 0, change.courier && {
4031
+ ...change.courier,
4032
+ owned: (owner) => {
4033
+ if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
4034
+ }
2748
4035
  });
4036
+ return result;
4037
+ }
4038
+ /** The project a version puts its board in: a new board's repository's,
4039
+ * or, for a board that has one, its own unless the version moves it to
4040
+ * another registered repository. */
4041
+ projectFor(snapshot, previous) {
4042
+ const repository = snapshot.target?.repositoryId;
4043
+ if (previous && (!repository || repository === previous.target?.repositoryId || !this.db.prepare("SELECT 1 FROM repositories WHERE id=?").get(repository))) return this.projectOf(snapshot.reviewId);
4044
+ return repository ? this.projectOfRepository(repository) : this.fallbackProject();
4045
+ }
4046
+ currentVersion(reviewId) {
4047
+ return Number(this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId).version);
4048
+ }
4049
+ /** Runs one write after every write queued before it. */
4050
+ enqueue(work) {
4051
+ if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Review store is closing."));
4052
+ const run = this.pending.then(work);
2749
4053
  this.pending = run.catch(() => {});
2750
4054
  return run;
2751
4055
  }
4056
+ /** Respells a document's links and resolves its commit links, as every
4057
+ * save does. A changed reference is no longer stale. */
4058
+ async normalizeDocument(snapshot, previous) {
4059
+ snapshot.document = await this.resolveCommitLinks(respellDocumentLinks(snapshot.document), snapshot, previous.document);
4060
+ if (snapshot.staleSources?.length) {
4061
+ const oldSources = new Map(sourceReferences(previous.document, { repositoryId: boardRepositoryId(previous) }).map((item) => [item.id, JSON.stringify(item.source)]));
4062
+ const newSources = new Map(sourceReferences(snapshot.document, { repositoryId: boardRepositoryId(snapshot) }).map((item) => [item.id, JSON.stringify(item.source)]));
4063
+ snapshot.staleSources = snapshot.staleSources.filter((id) => oldSources.get(id) === newSources.get(id) && newSources.has(id));
4064
+ }
4065
+ }
4066
+ /** Checks a candidate version and numbers it. A worktree board's version
4067
+ * may come back with its live source projected. */
4068
+ async prepareVersion(candidate, previous, options) {
4069
+ const snapshot = candidate.target?.kind === "worktree" && options.projectLive ? await this.projectLiveSource(candidate) : candidate;
4070
+ checkReferences(snapshot.document);
4071
+ if (!options.restore) this.checkBoardLinks(snapshot, previous, options.person);
4072
+ if (snapshot.pins && (!previous || JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))) await this.providers.validatePins(snapshot.pins);
4073
+ const advised = new Set(documentAdvice(previous?.document ?? []));
4074
+ const warnings = [...documentAdvice(snapshot.document).filter((advice) => !advised.has(advice)), ...await this.validateExternal(snapshot, previous, options.repin)];
4075
+ snapshot.version = previous ? previous.version + 1 : 0;
4076
+ snapshot.createdAt = (/* @__PURE__ */ new Date()).toISOString();
4077
+ return {
4078
+ snapshot,
4079
+ warnings
4080
+ };
4081
+ }
4082
+ /** Writes a version; call inside commitCommand. */
4083
+ saveVersion(snapshot, nextId, projectId, author) {
4084
+ const id = snapshot.reviewId;
4085
+ this.db.prepare("INSERT INTO reviews(id,version,next_id,project_id) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id,project_id=excluded.project_id").run(id, snapshot.version, nextId, projectId);
4086
+ this.db.prepare("INSERT INTO versions(review_id,version,snapshot,thread_id,author_kind) VALUES(?,?,?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot), versionThread(snapshot), author.kind);
4087
+ this.indexBoardLinks(id, snapshot.document);
4088
+ }
4089
+ /** Deletes a board and its history, if it is still at the version read
4090
+ * and passes `guard`: Delete… from Trash, Trash's purge and a discarded
4091
+ * empty board all end here. An agent can't delete a board while a person
4092
+ * holds a block in it. */
4093
+ deleteBoard({ reviewId, version }, author, { guard, fromTrash = false } = {}) {
4094
+ const result = {
4095
+ reviewId,
4096
+ version,
4097
+ deleted: true
4098
+ };
4099
+ const rules = () => {
4100
+ guard?.();
4101
+ if (fromTrash && !this.trashTime(reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
4102
+ this.assertMutation(reviewId, version);
4103
+ if (author.kind !== "person") this.assertUnheld(reviewId);
4104
+ };
4105
+ rules();
4106
+ this.commitCommand(result, () => this.removePermanently(reviewId), rules);
4107
+ this.leases.delete(reviewId);
4108
+ return result;
4109
+ }
4110
+ /** Writes what a board is outside its versions: its parent or what it
4111
+ * depends on, Trash or back, or that it was viewed. No version is saved,
4112
+ * but the rules are a version's: a board in Trash takes nothing but a
4113
+ * restore, and an agent can't put a board a person is in to Trash. */
4114
+ commitBoardFacts(reviewId, author, fact) {
4115
+ const lifecycle = "state" in fact;
4116
+ const rules = () => {
4117
+ this.assertExists(reviewId);
4118
+ if (!lifecycle) this.assertOutOfTrash(reviewId);
4119
+ else if (fact.state === "trash" && author.kind !== "person") this.assertUnheld(reviewId);
4120
+ };
4121
+ rules();
4122
+ const result = {
4123
+ reviewId,
4124
+ version: this.read(reviewId).version
4125
+ };
4126
+ if (lifecycle || "viewed" in fact) result.attention = true;
4127
+ if (lifecycle) result.lifecycle = true;
4128
+ this.commitCommand(result, () => {
4129
+ if ("parentId" in fact) this.setParent(reviewId, fact.parentId);
4130
+ else if ("dependsOn" in fact) this.setDependencies(reviewId, fact.dependsOn);
4131
+ else {
4132
+ this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(reviewId);
4133
+ if ("viewed" in fact) this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), reviewId);
4134
+ else {
4135
+ const timestamp = fact.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
4136
+ this.db.prepare("UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?").run(timestamp, timestamp, reviewId);
4137
+ }
4138
+ }
4139
+ if ("parentId" in fact || "dependsOn" in fact) result.relations = this.relations(reviewId);
4140
+ }, rules);
4141
+ return result;
4142
+ }
4143
+ /** Deletes a board the person made and left as it was: with no title
4144
+ * (it reads "Untitled"), no blocks and no child boards. A title typed as
4145
+ * "Untitled" is a title. A board still open on a canvas, or
4146
+ * one an agent is working on, is kept. A closing canvas saves, then lets
4147
+ * go of the board, so the discard waits for that first. */
4148
+ async discard(reviewId) {
4149
+ const deadline = Date.now() + FOCUS_LEASE_TTL_MS;
4150
+ while (this.leased(reviewId) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
4151
+ return this.enqueue(async () => {
4152
+ if (!this.has(reviewId)) return { deleted: false };
4153
+ const snapshot = this.read(reviewId);
4154
+ if (this.leased(reviewId) || this.activity.isWorking(reviewId) || snapshot.title !== "" || snapshot.document.length || this.relations(reviewId).children.length) return { deleted: false };
4155
+ this.deleteBoard(snapshot, { kind: "person" });
4156
+ return { deleted: true };
4157
+ });
4158
+ }
4159
+ /** Each board's canvas leases by client: the board is open there, and
4160
+ * `blockId` is the block the person is in, if any. In memory: a lease
4161
+ * outlives neither its renewals nor the host. */
4162
+ leases = /* @__PURE__ */ new Map();
4163
+ /** Whether a canvas still holds the board open. */
4164
+ leased(reviewId) {
4165
+ const now = Date.now();
4166
+ return [...this.leases.get(reviewId)?.values() ?? []].some((lease) => lease.expiresAt > now);
4167
+ }
4168
+ /** Records the block a person's canvas has focus in (null for none), or
4169
+ * with `open: false` that the canvas closed the board. */
4170
+ focus(reviewId, input) {
4171
+ const { clientId, blockId, open } = focusLeaseSchema.parse(input);
4172
+ this.assertExists(reviewId);
4173
+ const leases = this.leases.get(reviewId) ?? /* @__PURE__ */ new Map();
4174
+ if (open === false) leases.delete(clientId);
4175
+ else leases.set(clientId, {
4176
+ blockId,
4177
+ expiresAt: Date.now() + FOCUS_LEASE_TTL_MS
4178
+ });
4179
+ if (leases.size) this.leases.set(reviewId, leases);
4180
+ else this.leases.delete(reviewId);
4181
+ return { ok: true };
4182
+ }
4183
+ /** Refuses an agent's change to a board while a person holds any block
4184
+ * in it. */
4185
+ assertUnheld(reviewId) {
4186
+ const now = Date.now();
4187
+ for (const lease of this.leases.get(reviewId)?.values() ?? []) if (lease.expiresAt > now && lease.blockId) throw new ReviewInputError(`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`, 409);
4188
+ }
4189
+ /** Refuses an agent's edit to a block a person is editing. */
4190
+ assertUnleased(reviewId, changed) {
4191
+ const leases = this.leases.get(reviewId);
4192
+ if (!leases || !changed.length) return;
4193
+ const now = Date.now();
4194
+ for (const [clientId, lease] of leases) if (lease.expiresAt <= now) leases.delete(clientId);
4195
+ else if (lease.blockId && changed.includes(lease.blockId)) throw new ReviewInputError(`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`, 409);
4196
+ }
4197
+ /** Saves a person's whole board as their own version, against the version
4198
+ * they started from. Blocks without ids get fresh ones. */
4199
+ personEdit(reviewId, input) {
4200
+ const body = personEditSchema.parse(input);
4201
+ return this.enqueue(async () => {
4202
+ const previous = this.read(reviewId);
4203
+ assertBaseVersion(body.baseVersion, previous.version);
4204
+ const { document, nextId, ids } = personDocument(body.document, Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(reviewId).next_id));
4205
+ const snapshot = structuredClone(previous);
4206
+ delete snapshot.sourceUnavailable;
4207
+ delete snapshot.lastEdit;
4208
+ snapshot.title = body.title;
4209
+ snapshot.document = document;
4210
+ const { version } = await this.commitVersion({
4211
+ snapshot,
4212
+ previous,
4213
+ nextId,
4214
+ author: { kind: "person" },
4215
+ projectLive: true,
4216
+ personIds: ids,
4217
+ baseVersion: body.baseVersion,
4218
+ unchangedSavesNothing: true
4219
+ });
4220
+ return {
4221
+ reviewId,
4222
+ version,
4223
+ ids
4224
+ };
4225
+ });
4226
+ }
4227
+ /** Where a board sits: its parent, and the parent's children in order,
4228
+ * each with what its header shows. */
4229
+ place(id) {
4230
+ const graph = this.relationGraph(true);
4231
+ const parentId = graph.parents.get(id);
4232
+ const placed = (reviewId) => {
4233
+ const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title,
4234
+ json_extract(versions.snapshot,'$.target') AS target,
4235
+ json_extract(versions.snapshot,'$.origin') AS origin
4236
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
4237
+ WHERE reviews.id=?`).get(reviewId);
4238
+ return {
4239
+ reviewId,
4240
+ title: String(row.title),
4241
+ ...row.target && { target: JSON.parse(String(row.target)) },
4242
+ ...row.origin && { origin: JSON.parse(String(row.origin)) }
4243
+ };
4244
+ };
4245
+ return {
4246
+ board: placed(id),
4247
+ ...parentId && { parent: placed(parentId) },
4248
+ siblings: parentId ? this.orderChildren(this.listedSiblings(graph.children.get(parentId), id), graph).map(placed) : [],
4249
+ ...!parentId && { children: this.orderChildren(this.listedSiblings(graph.children.get(id) ?? [], id), graph).map(placed) }
4250
+ };
4251
+ }
4252
+ /** The siblings Home and the sidebar list, which a board's place counts
4253
+ * among: all but those in Trash, and the board itself. */
4254
+ listedSiblings(ids, self) {
4255
+ const trashed = new Set(this.trashedIds());
4256
+ return ids.filter((id) => id === self || !trashed.has(id));
4257
+ }
4258
+ /** A board's parent, its children in sibling order, and what it depends on. */
4259
+ relations(id) {
4260
+ const graph = this.relationGraph();
4261
+ return {
4262
+ parentId: graph.parents.get(id) ?? null,
4263
+ children: this.orderChildren(graph.children.get(id) ?? [], graph),
4264
+ dependsOn: graph.dependsOn.get(id) ?? []
4265
+ };
4266
+ }
4267
+ /** Parents, children and dependencies. A board in Trash is out of them
4268
+ * until it is restored, its rows kept; only its own place sees it. */
4269
+ relationGraph(withTrash = false) {
4270
+ const parents = /* @__PURE__ */ new Map();
4271
+ const children = /* @__PURE__ */ new Map();
4272
+ const dependsOn = /* @__PURE__ */ new Map();
4273
+ const trashed = new Set(withTrash ? [] : this.trashedIds());
4274
+ for (const row of this.db.prepare("SELECT id, parent_id FROM reviews WHERE parent_id IS NOT NULL ORDER BY rowid").all()) {
4275
+ const board = String(row.id);
4276
+ const parent = String(row.parent_id);
4277
+ if (trashed.has(board) || trashed.has(parent)) continue;
4278
+ parents.set(board, parent);
4279
+ children.set(parent, [...children.get(parent) ?? [], board]);
4280
+ }
4281
+ for (const row of this.db.prepare("SELECT review_id, depends_on FROM board_dependencies ORDER BY rowid").all()) {
4282
+ const board = String(row.review_id);
4283
+ const dependency = String(row.depends_on);
4284
+ if (trashed.has(board) || trashed.has(dependency)) continue;
4285
+ dependsOn.set(board, [...dependsOn.get(board) ?? [], dependency]);
4286
+ }
4287
+ return {
4288
+ parents,
4289
+ children,
4290
+ dependsOn
4291
+ };
4292
+ }
4293
+ /** Drops a board's parent, children and dependencies in other projects,
4294
+ * after it moved to `projectId`. Whether it dropped any. */
4295
+ dropRelationsOutside(id, projectId) {
4296
+ const outside = "SELECT id FROM reviews WHERE project_id!=?";
4297
+ const parents = this.db.prepare(`UPDATE reviews SET parent_id=NULL WHERE (id=? AND parent_id IN (${outside})) OR (parent_id=? AND project_id!=?)`).run(id, projectId, id, projectId).changes;
4298
+ const dependencies = this.db.prepare(`DELETE FROM board_dependencies WHERE (review_id=? AND depends_on IN (${outside})) OR (depends_on=? AND review_id IN (${outside}))`).run(id, projectId, id, projectId).changes;
4299
+ return Number(parents) + Number(dependencies) > 0;
4300
+ }
4301
+ orderChildren(children, graph) {
4302
+ if (children.length < 2) return [...children];
4303
+ return orderSiblings(this.siblingFacts(children), (id) => graph.dependsOn.get(id) ?? [], (repositoryId, ancestor, of) => this.isAncestor(repositoryId, ancestor, of));
4304
+ }
4305
+ siblingFacts(ids) {
4306
+ return this.db.prepare(`SELECT reviews.id,
4307
+ (SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first
4308
+ WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS created_at,
4309
+ json_extract(versions.snapshot,'$.pins') AS pins,
4310
+ json_extract(versions.snapshot,'$.origin.branch') AS branch,
4311
+ json_extract(versions.snapshot,'$.origin.pullRequestUrl') AS url
4312
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
4313
+ WHERE reviews.id IN (${ids.map(() => "?").join(",")})`).all(...ids).map((row) => {
4314
+ const id = String(row.id);
4315
+ const saved = row.pins ? JSON.parse(String(row.pins)) : void 0;
4316
+ const pins = this.liveSources.get(id)?.pins ?? saved;
4317
+ const url = row.url ? String(row.url) : void 0;
4318
+ const baseBranch = url && this.providers.pullRequestBaseBranch?.(url);
4319
+ return {
4320
+ id,
4321
+ createdAt: String(row.created_at),
4322
+ ...pins && {
4323
+ repositoryId: pins.repositoryId,
4324
+ base: pins.base,
4325
+ head: pins.head
4326
+ },
4327
+ ...url && row.branch && { headBranch: String(row.branch) },
4328
+ ...baseBranch && { baseBranch }
4329
+ };
4330
+ });
4331
+ }
4332
+ /** Commits never change, so a definite answer is kept. */
4333
+ ancestry = /* @__PURE__ */ new Map();
4334
+ /** When git was last asked, for questions with no answer yet. */
4335
+ ancestryAsked = /* @__PURE__ */ new Map();
4336
+ /** Whether `ancestor` is an ancestor of `of`, as far as is known. git is
4337
+ * asked in the background, never on the server thread: until it answers,
4338
+ * or when it fails, the answer is no, and a yes re-sorts the catalog. */
4339
+ isAncestor(repositoryId, ancestor, of) {
4340
+ const key = `${repositoryId}:${ancestor}:${of}`;
4341
+ const known = this.ancestry.get(key);
4342
+ if (known !== void 0) return known;
4343
+ const asked = this.ancestryAsked.get(key);
4344
+ if (asked !== void 0 && Date.now() - asked < 6e4) return false;
4345
+ this.ancestryAsked.set(key, Date.now());
4346
+ let root;
4347
+ try {
4348
+ root = this.repositoryPath(repositoryId);
4349
+ } catch {
4350
+ return false;
4351
+ }
4352
+ this.ancestryQueue.push(() => runVcs("git", [
4353
+ "-C",
4354
+ root,
4355
+ "merge-base",
4356
+ "--is-ancestor",
4357
+ { rev: ancestor },
4358
+ { rev: of }
4359
+ ], { timeout: 1e4 }).then(() => this.knowAncestry(key, true), (error) => {
4360
+ if (error.code === 1) this.knowAncestry(key, false);
4361
+ }));
4362
+ this.askNextAncestry();
4363
+ return false;
4364
+ }
4365
+ ancestryQueue = [];
4366
+ ancestryRunning = 0;
4367
+ askNextAncestry() {
4368
+ while (this.ancestryRunning < ANCESTRY_CONCURRENCY && this.ancestryQueue.length) {
4369
+ this.ancestryRunning++;
4370
+ this.ancestryQueue.shift()().finally(() => {
4371
+ this.ancestryRunning--;
4372
+ this.askNextAncestry();
4373
+ });
4374
+ }
4375
+ }
4376
+ knowAncestry(key, ancestor) {
4377
+ this.ancestry.set(key, ancestor);
4378
+ this.ancestryAsked.delete(key);
4379
+ if (ancestor && !this.closing) this.catalogChanged();
4380
+ }
4381
+ catalogChanged() {
4382
+ for (const listener of this.catalogListeners) try {
4383
+ listener();
4384
+ } catch {}
4385
+ }
4386
+ boardName(id) {
4387
+ return `"${boardTitle(this.read(id).title)}" (${id})`;
4388
+ }
4389
+ /** The other board exists and is in the same project. */
4390
+ checkRelated(id, other) {
4391
+ if (!this.has(other)) throw new ReviewInputError(`Whiteboard ${other} does not exist. Name a whiteboard by its sessionId from session_list.`);
4392
+ if (this.projectOf(id) !== this.projectOf(other)) throw new ReviewInputError(`${this.boardName(other)} is in another project than ${this.boardName(id)}. A whiteboard's parent and dependencies are in its own project.`);
4393
+ }
4394
+ /** One parent, one level: a parent is top-level and a child has no
4395
+ * children. Null makes the board top-level. */
4396
+ setParent(id, parentId) {
4397
+ if (parentId !== null) {
4398
+ if (parentId === id) throw new ReviewInputError("A whiteboard can't be its own parent.");
4399
+ this.checkRelated(id, parentId);
4400
+ const { parents, children } = this.relationGraph();
4401
+ const grandparent = parents.get(parentId);
4402
+ if (grandparent) throw new ReviewInputError(`${this.boardName(parentId)} is a child of ${this.boardName(grandparent)}, and whiteboards nest one level deep. Use ${grandparent} as the parent instead.`);
4403
+ const own = children.get(id) ?? [];
4404
+ if (own.length) throw new ReviewInputError(`${this.boardName(id)} has children (${own.map((child) => this.boardName(child)).join(", ")}), and whiteboards nest one level deep, so it can't become a child. Move its children first with session_set_parent.`);
4405
+ }
4406
+ this.db.prepare("UPDATE reviews SET parent_id=? WHERE id=?").run(parentId, id);
4407
+ }
4408
+ setDependencies(id, dependsOn) {
4409
+ const unique = [...new Set(dependsOn)];
4410
+ const graph = this.relationGraph();
4411
+ for (const dependency of unique) {
4412
+ if (dependency === id) throw new ReviewInputError("A whiteboard can't depend on itself.");
4413
+ const cycle = dependencyCycle(id, dependency, (board) => graph.dependsOn.get(board) ?? []);
4414
+ if (cycle) throw new ReviewInputError(`Depending on ${this.boardName(dependency)} would close a cycle: ${cycle.map((board) => this.boardName(board)).join(" → ")}. Drop one of those dependencies first.`);
4415
+ this.checkRelated(id, dependency);
4416
+ }
4417
+ this.db.prepare("DELETE FROM board_dependencies WHERE review_id=?").run(id);
4418
+ const insert = this.db.prepare("INSERT INTO board_dependencies(review_id,depends_on) VALUES(?,?)");
4419
+ for (const dependency of unique) insert.run(id, dependency);
4420
+ }
4421
+ /** New links must point at a board and block that exist. Saved links are
4422
+ * not rechecked, since their target may have been deleted. */
4423
+ checkBoardLinks(snapshot, previous, person = false) {
4424
+ const key = ({ to }) => `${to.board}#${to.block ?? ""}`;
4425
+ const kept = new Set(boardLinks(snapshot.reviewId, previous?.document ?? []).map(key));
4426
+ for (const link of boardLinks(snapshot.reviewId, snapshot.document)) {
4427
+ if (kept.has(key(link))) continue;
4428
+ const { from, to } = link;
4429
+ const document = to.board === snapshot.reviewId ? snapshot.document : this.has(to.board) ? this.read(to.board).document : void 0;
4430
+ if (!document && person && this.wasDeleted(to.board)) continue;
4431
+ if (!document) throw new ReviewInputError(`Block ${from.block} links to board ${to.board}, which does not exist. Link a board by its sessionId from session_list.`);
4432
+ if (!person && to.block !== void 0 && !elements(document).some((element) => element.id === to.block)) throw new ReviewInputError(`Block ${from.block} links to block ${to.block}, which is not on board ${to.board}. Read the board with session_get for its block ids.`);
4433
+ }
4434
+ }
4435
+ wasDeleted(id) {
4436
+ return this.db.prepare("SELECT 1 FROM deleted_boards WHERE id=?").get(id) !== void 0;
4437
+ }
4438
+ /** The boards that link to a board or its blocks. */
4439
+ linkingBoards(id) {
4440
+ return this.db.prepare("SELECT DISTINCT from_review FROM board_links WHERE to_review=?").all(id).map((row) => String(row.from_review));
4441
+ }
4442
+ /** Rebuilds the board_links rows for one board. */
4443
+ indexBoardLinks(id, document) {
4444
+ this.db.prepare("DELETE FROM board_links WHERE from_review=?").run(id);
4445
+ const insert = this.db.prepare("INSERT INTO board_links(from_review,from_block,to_review,to_block) VALUES(?,?,?,?)");
4446
+ for (const { from, to } of boardLinks(id, document)) insert.run(from.board, from.block, to.board, to.block ?? null);
4447
+ }
4448
+ /** Link previews by board, version and block; a version never changes. */
4449
+ linkPreviews = /* @__PURE__ */ new Map();
4450
+ linkPreview(target, block) {
4451
+ const key = `${target.reviewId}@${target.version}#${block ?? ""}`;
4452
+ let preview = this.linkPreviews.get(key);
4453
+ if (!preview) {
4454
+ preview = boardLinkPreview(target.document, block);
4455
+ if (this.linkPreviews.size >= 500) this.linkPreviews.delete(this.linkPreviews.keys().next().value);
4456
+ this.linkPreviews.set(key, preview);
4457
+ }
4458
+ return preview;
4459
+ }
4460
+ /** What a link's hover card shows of the board or block it links to,
4461
+ * read when the card opens rather than with every link. Only for a link
4462
+ * the version has. */
4463
+ linkPeek(id, to, version) {
4464
+ if (!boardLinks(id, this.read(id, version).document).some((link) => link.to.board === to.board && link.to.block === to.block) || !this.has(to.board)) throw new ReviewInputError("The board has no such link.", 404);
4465
+ return this.linkPreview(this.read(to.board), to.block);
4466
+ }
4467
+ /** "owner / repo" for a board in a GitHub repository, else its folder. */
4468
+ repositoryLabel(board) {
4469
+ const repositoryId = board.target?.repositoryId;
4470
+ if (!repositoryId) return void 0;
4471
+ try {
4472
+ return this.repositoryGroup(this.repositoryPath(repositoryId))?.label.split("/").join(" / ");
4473
+ } catch {
4474
+ return;
4475
+ }
4476
+ }
4477
+ /** A version's outgoing links and the board's current backlinks. Links to a
4478
+ * deleted board or block are marked missing. */
4479
+ links(id, version) {
4480
+ const titles = /* @__PURE__ */ new Map();
4481
+ const board = (reviewId) => {
4482
+ if (!titles.has(reviewId)) titles.set(reviewId, this.has(reviewId) ? this.read(reviewId) : void 0);
4483
+ return titles.get(reviewId);
4484
+ };
4485
+ return {
4486
+ outgoing: boardLinks(id, this.read(id, version).document).map(({ from, to }) => {
4487
+ const target = board(to.board);
4488
+ const missing = !target || to.block !== void 0 && !elements(target.document).some((element) => element.id === to.block);
4489
+ const deleted = target ? void 0 : this.db.prepare("SELECT title,deleted_at FROM deleted_boards WHERE id=?").get(to.board);
4490
+ return {
4491
+ from,
4492
+ to,
4493
+ ...target && {
4494
+ title: target.title,
4495
+ icon: boardIconKind(target.target),
4496
+ ...this.repositoryLabel(target) && { repository: this.repositoryLabel(target) },
4497
+ ...target.origin?.pullRequestNumber && {
4498
+ pullRequestNumber: target.origin.pullRequestNumber,
4499
+ pullRequestUrl: target.origin.pullRequestUrl
4500
+ },
4501
+ updatedAt: target.createdAt
4502
+ },
4503
+ missing,
4504
+ ...deleted && {
4505
+ title: String(deleted.title),
4506
+ deletedAt: String(deleted.deleted_at)
4507
+ }
4508
+ };
4509
+ }),
4510
+ backlinks: this.db.prepare("SELECT DISTINCT from_review,from_block,to_block FROM board_links WHERE to_review=? AND from_review<>? ORDER BY from_review").all(id, id).map((row) => {
4511
+ const from = board(String(row.from_review));
4512
+ return {
4513
+ from: {
4514
+ board: String(row.from_review),
4515
+ block: String(row.from_block)
4516
+ },
4517
+ to: row.to_block === null ? { board: id } : {
4518
+ board: id,
4519
+ block: String(row.to_block)
4520
+ },
4521
+ title: from?.title ?? "",
4522
+ ...from && { icon: boardIconKind(from.target) },
4523
+ ...from?.origin?.pullRequestNumber && {
4524
+ pullRequestNumber: from.origin.pullRequestNumber,
4525
+ pullRequestUrl: from.origin.pullRequestUrl
4526
+ }
4527
+ };
4528
+ })
4529
+ };
4530
+ }
4531
+ /** Boards edited by an Ask thread, most recent first. */
4532
+ boardsChangedBy(threadId) {
4533
+ return this.db.prepare(`SELECT edits.review_id, MAX(edits.version) AS last
4534
+ FROM versions edits
4535
+ JOIN reviews ON reviews.id=edits.review_id
4536
+ WHERE edits.thread_id=?
4537
+ GROUP BY edits.review_id ORDER BY MAX(json_extract(edits.snapshot,'$.createdAt')) DESC`).all(threadId).map((row) => {
4538
+ const board = this.read(String(row.review_id));
4539
+ const blockId = this.read(board.reviewId, Number(row.last)).lastEdit?.blockId;
4540
+ return {
4541
+ reviewId: board.reviewId,
4542
+ title: board.title,
4543
+ icon: boardIconKind(board.target),
4544
+ ...blockId && elements(board.document).some((element) => element.id === blockId) && { blockId }
4545
+ };
4546
+ });
4547
+ }
4548
+ /** The boards each thread changed, newest first, for many threads in one
4549
+ * query. */
4550
+ /** A board's title and icon, read without its document. */
4551
+ /** Where a board lives, as Recent names it: its checkout's folder, else
4552
+ * its project's name. */
4553
+ boardPlace(reviewId) {
4554
+ const row = this.db.prepare(`SELECT repositories.path, projects.name FROM reviews
4555
+ JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
4556
+ LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
4557
+ LEFT JOIN projects ON projects.id=reviews.project_id
4558
+ WHERE reviews.id=?`).get(reviewId);
4559
+ return row?.path && path.basename(String(row.path)) || (row?.name ? String(row.name) : "");
4560
+ }
4561
+ boardFace(reviewId) {
4562
+ const row = this.db.prepare(`SELECT json_extract(versions.snapshot,'$.title') AS title, json_extract(versions.snapshot,'$.target') AS target
4563
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
4564
+ WHERE reviews.id=?`).get(reviewId);
4565
+ return {
4566
+ reviewId,
4567
+ title: String(row.title),
4568
+ icon: boardIconKind(row.target ? JSON.parse(String(row.target)) : void 0)
4569
+ };
4570
+ }
4571
+ boardsChangedByThreads(threadIds) {
4572
+ const changed = /* @__PURE__ */ new Map();
4573
+ if (!threadIds.length) return changed;
4574
+ const rows = this.db.prepare(`SELECT thread_id AS thread, review_id
4575
+ FROM versions
4576
+ WHERE thread_id IN (${threadIds.map(() => "?").join(",")})
4577
+ GROUP BY thread, review_id ORDER BY MAX(rowid) DESC`).all(...threadIds);
4578
+ const boards = /* @__PURE__ */ new Map();
4579
+ for (const row of rows) {
4580
+ const reviewId = String(row.review_id);
4581
+ if (!boards.has(reviewId)) boards.set(reviewId, this.has(reviewId) ? this.boardFace(reviewId) : void 0);
4582
+ const board = boards.get(reviewId);
4583
+ if (!board) continue;
4584
+ const thread = String(row.thread);
4585
+ changed.set(thread, [...changed.get(thread) ?? [], board]);
4586
+ }
4587
+ return changed;
4588
+ }
2752
4589
  /** Resolve a target-less PR create before it queues, when it will need it. */
2753
4590
  startPullRequest(command) {
2754
4591
  const op = command.operation;
2755
- if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl) return void 0;
4592
+ if (op.type !== "create" || op.target || !op.pullRequestUrl) return void 0;
2756
4593
  if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
4594
+ if (!op.repositoryId) return Promise.reject(new ReviewInputError("Name the checkout to fetch the PR into: target {kind:\"pullRequest\", repositoryPath, pullRequest}."));
2757
4595
  const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
2758
4596
  if (existing) try {
2759
4597
  this.assertReusableReview(existing);
2760
4598
  } catch (error) {
2761
4599
  return Promise.reject(error);
2762
4600
  }
2763
- return this.providers.resolvePullRequest(op.pullRequestUrl, {
2764
- id: op.repositoryId,
2765
- preferred: existing && this.read(existing).pins?.repositoryId
2766
- });
4601
+ return this.providers.resolvePullRequest(op.pullRequestUrl, { id: op.repositoryId });
2767
4602
  }
2768
4603
  startPullRequestTitle(command) {
2769
4604
  const op = command.operation;
@@ -2785,7 +4620,7 @@ var ReviewStore = class {
2785
4620
  existingReview(reviewId, others, requested) {
2786
4621
  const snapshot = this.read(reviewId);
2787
4622
  this.assertReusableReview(reviewId);
2788
- const headMoved = snapshot.pins?.repositoryId !== requested.repositoryId || snapshot.pins?.head !== requested.head;
4623
+ const headMoved = snapshot.pins?.head !== requested.head;
2789
4624
  const working = this.activity.read(reviewId).activities ?? [];
2790
4625
  return {
2791
4626
  created: false,
@@ -2830,7 +4665,7 @@ var ReviewStore = class {
2830
4665
  notify(result) {
2831
4666
  if (result.deleted) this.observedVersions.delete(result.reviewId);
2832
4667
  else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
2833
- if (!result.attention || result.lifecycle) for (const listener of this.listeners) try {
4668
+ if ((!result.attention || result.lifecycle) && !result.relations) for (const listener of this.listeners) try {
2834
4669
  listener(result);
2835
4670
  } catch {}
2836
4671
  for (const listener of this.catalogListeners) try {
@@ -2844,9 +4679,26 @@ var ReviewStore = class {
2844
4679
  has(reviewId) {
2845
4680
  return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
2846
4681
  }
4682
+ /** Saves each new commit link's commit in full, resolved in the board's
4683
+ * repository; one it does not have rejects the edit, as pins do. A link the
4684
+ * board already had stays as it was saved, even if its commit is gone. */
4685
+ async resolveCommitLinks(document, board, previous) {
4686
+ const saved = new Set(previous ? commitLinkCommits(previous) : []);
4687
+ const commits = commitLinkCommits(document).filter((commit) => !saved.has(commit));
4688
+ const repositoryId = boardRepositoryId(board);
4689
+ if (!commits.length || !repositoryId || !this.providers.resolveCommit) return document;
4690
+ const resolved = /* @__PURE__ */ new Map();
4691
+ await Promise.all(commits.map(async (commit) => {
4692
+ const full = await this.providers.resolveCommit(repositoryId, commit);
4693
+ if (!full) throw new ReviewInputError(`Commit ${commit} is not in the board's repository. Fetch it first, or link a commit the repository has.`);
4694
+ resolved.set(commit, full);
4695
+ }));
4696
+ return respellCommitLinks(document, resolved);
4697
+ }
2847
4698
  async validateExternal(snapshot, previous, repin = false) {
2848
4699
  const warnings = [];
2849
- const references = (document, tolerant = false, lenses = []) => {
4700
+ const references = (board, tolerant = false) => {
4701
+ const { document, lenses = [] } = board;
2850
4702
  const sources = /* @__PURE__ */ new Map();
2851
4703
  const resources = /* @__PURE__ */ new Map();
2852
4704
  const add = (source, peek) => {
@@ -2857,7 +4709,10 @@ var ReviewStore = class {
2857
4709
  peek: peek || (kept?.peek ?? false)
2858
4710
  });
2859
4711
  };
2860
- for (const { source, peek } of sourceReferences(document, { tolerant })) add(source, peek === true);
4712
+ for (const { source, peek } of sourceReferences(document, {
4713
+ tolerant,
4714
+ repositoryId: boardRepositoryId(board)
4715
+ })) add(source, peek === true);
2861
4716
  for (const { source } of lensSelections(lenses)) for (const anchor of sourceAnchors(source)) add(anchor, false);
2862
4717
  for (const block of resourceReferences(document)) resources.set(JSON.stringify(block), block);
2863
4718
  return {
@@ -2865,18 +4720,18 @@ var ReviewStore = class {
2865
4720
  resources
2866
4721
  };
2867
4722
  };
2868
- const current = references(snapshot.document, repin, snapshot.lenses);
4723
+ const current = references(snapshot, repin);
2869
4724
  const pinsChanged = previous && JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins);
2870
4725
  const worktreeMoved = pinsChanged && snapshot.target?.kind === "worktree";
2871
4726
  const changed = repin && pinsChanged && previous.pins && snapshot.pins ? await this.providers.filesChangedBetween?.(previous.pins, snapshot.pins).catch(() => void 0) : void 0;
2872
- const retained = references(previous?.document ?? [], true, previous?.lenses);
4727
+ const retained = references(previous ?? { document: [] }, true);
2873
4728
  const checks = [];
2874
4729
  const retainedPins = new Set(explicitPins([...retained.sources.values()]).map((pins) => JSON.stringify(pins)));
2875
4730
  for (const pins of explicitPins([...current.sources.values()])) if (!retainedPins.has(JSON.stringify(pins))) checks.push(this.providers.validatePins(pins));
2876
4731
  for (const [key, { source, peek }] of current.sources) {
2877
4732
  const kept = retained.sources.get(key);
2878
4733
  if (pinsChanged && !source.pins || !kept || peek && !kept.peek) checks.push(this.providers.validateSource(anchorPins(source, snapshot.pins), source, { peek }).then(() => {
2879
- 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.`);
4734
+ if (repin && !readsWhereItDid(source, previous?.pins) && (!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.`);
2880
4735
  }, (error) => {
2881
4736
  if (!repin && !(worktreeMoved && kept) || !(error instanceof ReviewInputError)) throw error;
2882
4737
  warnings.push(`${source.side}/${source.file}#L${source.fromLine}-L${source.toLine}: ${error.message}`);
@@ -2890,6 +4745,106 @@ var ReviewStore = class {
2890
4745
  return warnings.sort();
2891
4746
  }
2892
4747
  };
4748
+ /** The highest number in a document's `prefix-N` ids, units included. */
4749
+ function highestIdNumber(document) {
4750
+ let highest = 0;
4751
+ for (const [, number] of JSON.stringify(document).matchAll(/"id":"[^"]*?-(\d+)"/g)) highest = Math.max(highest, Number(number));
4752
+ return highest;
4753
+ }
4754
+ /** The title of a board a person made and has not named. */
4755
+ /** A person's save must start from the board's current version. */
4756
+ function assertBaseVersion(base, current) {
4757
+ if (base !== current) throw new ReviewInputError(`The board changed since version ${base}; it is now at version ${current}. Merge the latest version and save again.`, 409);
4758
+ }
4759
+ /**
4760
+ * A person's whole document as the store saves it: each new block's
4761
+ * clientKey taken, the blocks parsed, a flow's units adopted and every block
4762
+ * without an id given the next one after `nextId`. Every save then respells
4763
+ * its links (respellDocumentLinks) and resolves commit links it has not
4764
+ * saved before. Mutates `input`.
4765
+ */
4766
+ function personDocument(input, nextId) {
4767
+ const keys = takeClientKeys(input);
4768
+ const document = documentSchema.parse(input);
4769
+ const allocate = (prefix) => `${prefix}-${++nextId}`;
4770
+ adoptFlowUnits(document, allocate);
4771
+ for (const block of document) assignMissingIds(block, allocate);
4772
+ assertUniqueIds(document);
4773
+ return {
4774
+ document,
4775
+ nextId: Math.max(nextId, highestIdNumber(document)),
4776
+ ids: Object.fromEntries([...keys.byPath].map(([path, key]) => [key, blockAt(document, path).id]))
4777
+ };
4778
+ }
4779
+ const clientKeySchema = z.string().min(1).optional();
4780
+ /** Removes each block's clientKey from a person's raw document, keeping it
4781
+ * by the block's path when the block has no id. Counts the bare blocks. */
4782
+ function takeClientKeys(document) {
4783
+ const byPath = /* @__PURE__ */ new Map();
4784
+ const seen = /* @__PURE__ */ new Set();
4785
+ let bare = 0;
4786
+ const visit = (list, prefix) => list.forEach((block, index) => {
4787
+ if (!isJsonObject(block)) return;
4788
+ const path = `${prefix}${index}`;
4789
+ const clientKey = clientKeySchema.parse(block.clientKey);
4790
+ delete block.clientKey;
4791
+ if (block.id === void 0) {
4792
+ bare++;
4793
+ if (clientKey !== void 0) {
4794
+ if (seen.has(clientKey)) throw new ReviewInputError(`clientKey ${clientKey} names more than one block. Give each new block its own clientKey.`);
4795
+ seen.add(clientKey);
4796
+ byPath.set(path, clientKey);
4797
+ }
4798
+ }
4799
+ if (Array.isArray(block.children)) visit(block.children, `${path}/`);
4800
+ });
4801
+ visit(document, "");
4802
+ return {
4803
+ byPath,
4804
+ bare
4805
+ };
4806
+ }
4807
+ /** The block at a path takeClientKeys recorded. */
4808
+ function blockAt(document, path) {
4809
+ const [first, ...rest] = path.split("/").map(Number);
4810
+ let block = document[first];
4811
+ for (const index of rest) if ("children" in block) block = block.children[index];
4812
+ return block;
4813
+ }
4814
+ /** Ids name one component each across the whole document. */
4815
+ function assertUniqueIds(document) {
4816
+ const seen = /* @__PURE__ */ new Set();
4817
+ for (const { id } of elements(document)) {
4818
+ if (id === void 0) continue;
4819
+ if (seen.has(id)) throw new ReviewInputError(`Id ${id} names more than one component. Give each block its own id, or leave a new block's id out.`);
4820
+ seen.add(id);
4821
+ }
4822
+ }
4823
+ /** Each block's own content by id, nested blocks included. A section or
4824
+ * callout is kept without its children, so a change to a child is only the
4825
+ * child's. */
4826
+ function ownBlocks(document, into = /* @__PURE__ */ new Map()) {
4827
+ for (const block of document) if ("children" in block) {
4828
+ const { children, ...own } = block;
4829
+ if (block.id !== void 0) into.set(block.id, own);
4830
+ ownBlocks(children, into);
4831
+ } else if (block.id !== void 0) into.set(block.id, block);
4832
+ return into;
4833
+ }
4834
+ /** The blocks added, removed or changed from one document to the next. A
4835
+ * block moved with its content unchanged did not change. */
4836
+ function changedBlocks(before, after) {
4837
+ const old = ownBlocks(before);
4838
+ const now = ownBlocks(after);
4839
+ return [.../* @__PURE__ */ new Set([...old.keys(), ...now.keys()])].filter((id) => !isDeepStrictEqual(old.get(id), now.get(id)));
4840
+ }
4841
+ /** Whether a reference's own pins read the commit the board's previous
4842
+ * pins did. */
4843
+ function readsWhereItDid(source, previous) {
4844
+ if (!source.pins || !previous) return false;
4845
+ const commit = source.side === "base" ? source.pins.base ?? source.pins.head : source.pins.head;
4846
+ return source.pins.repositoryId === previous.repositoryId && (commit === previous.head || commit === previous.base);
4847
+ }
2893
4848
  /** Without a resolver, a commits target that names both commits pins them as
2894
4849
  * given; validatePins still checks they exist. */
2895
4850
  function namedCommits(target) {
@@ -2906,26 +4861,19 @@ function namedCommits(target) {
2906
4861
  }
2907
4862
  /** A new document's first version, before its initial content. Field order
2908
4863
  * is kept so stored JSON reads as it always has. */
2909
- function createdSnapshot(id, op, resolved, defaultTitle) {
2910
- const title = op.title ?? defaultTitle;
2911
- if (!title) throw new ReviewInputError("Supply a title.");
2912
- if (!resolved) return {
2913
- reviewId: id,
2914
- version: 0,
2915
- title,
2916
- kind: op.kind,
2917
- document: [],
2918
- createdAt: ""
2919
- };
2920
- return {
4864
+ function createdSnapshot(id, op, resolved, defaultTitle, untitled = false) {
4865
+ const title = op.title || defaultTitle || (untitled ? "" : void 0);
4866
+ if (title === void 0) throw new ReviewInputError("Supply a title.");
4867
+ const snapshot = {
2921
4868
  reviewId: id,
2922
4869
  version: 0,
2923
4870
  title,
2924
- pins: resolved.pins,
2925
4871
  target: resolved.target,
2926
4872
  document: [],
2927
4873
  createdAt: ""
2928
4874
  };
4875
+ if (resolved.pins) snapshot.pins = resolved.pins;
4876
+ return snapshot;
2929
4877
  }
2930
4878
  function inspectSnapshot(snapshot, targetId) {
2931
4879
  if (targetId !== void 0) {
@@ -2939,6 +4887,8 @@ function inspectSnapshot(snapshot, targetId) {
2939
4887
  label: "title" in element ? element.title : "label" in element ? element.label : element.type === "markdown" ? element.markdown.slice(0, 120) : void 0
2940
4888
  }));
2941
4889
  }
4890
+ /** How many ancestry questions git answers at once. */
4891
+ const ANCESTRY_CONCURRENCY = 8;
2942
4892
  //#endregion
2943
4893
  //#region src/review-api/version-cache.ts
2944
4894
  /** A stored version never changes, so work derived from it runs once. Keeps
@@ -2962,7 +4912,6 @@ var VersionCache = class {
2962
4912
  };
2963
4913
  //#endregion
2964
4914
  //#region src/review-api/workspaces.ts
2965
- const OWNED_ELSEWHERE = "Another Whiteboard server is managing this review's language workspaces. Retry once it stops.";
2966
4915
  /** Local lifecycle only: source and authored history never depend on preparation.
2967
4916
  * Status/queue/process lifecycle follows #334, retaining the legacy prepare config
2968
4917
  * and per-review managed checkout layout instead of a new settings system.
@@ -2980,7 +4929,8 @@ var ReviewWorkspaces = class {
2980
4929
  this.store = store;
2981
4930
  this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
2982
4931
  this.db.exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS pinned_environments(id TEXT PRIMARY KEY, value TEXT NOT NULL)");
2983
- 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)");
4932
+ if (!this.db.prepare("SELECT 1 FROM pragma_table_info('workspace_leases') WHERE name='started'").get()) this.db.exec("DROP TABLE IF EXISTS workspace_leases");
4933
+ 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,started TEXT)");
2984
4934
  this.db.exec("BEGIN IMMEDIATE");
2985
4935
  try {
2986
4936
  for (const environment of this.all()) {
@@ -3095,18 +5045,28 @@ var ReviewWorkspaces = class {
3095
5045
  if (!this.hasReview(id)) this.store.assertExists(id);
3096
5046
  }
3097
5047
  async remove(reviewId) {
3098
- if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
5048
+ if (!this.claim(reviewId)) throw new ReviewInputError(this.ownedElsewhere(reviewId), 409);
3099
5049
  await Promise.all(this.requests.values());
3100
5050
  this.collect(void 0, reviewId);
3101
5051
  await this.cleanup;
3102
5052
  if (this.all().some((item) => item.reviewId === reviewId)) throw new ReviewInputError("Could not remove the managed workspace. Retry deletion.", 409);
3103
5053
  }
3104
5054
  lease(reviewId) {
3105
- return this.db.prepare("SELECT owner,pid FROM workspace_leases WHERE review_id=?").get(reviewId);
5055
+ const row = this.db.prepare("SELECT owner,pid,started FROM workspace_leases WHERE review_id=?").get(reviewId);
5056
+ return row && {
5057
+ owner: String(row.owner),
5058
+ pid: Number(row.pid),
5059
+ started: String(row.started ?? "")
5060
+ };
3106
5061
  }
3107
5062
  leasedElsewhere(reviewId) {
3108
5063
  const lease = this.lease(reviewId);
3109
- return Boolean(lease && lease.owner !== this.ownerId && processIsAlive(Number(lease.pid)));
5064
+ return Boolean(lease && lease.owner !== this.ownerId && processIsAlive(lease.pid) && !pidReused(lease.pid, lease.started));
5065
+ }
5066
+ /** The holder may release its lease after `claim` fails. */
5067
+ ownedElsewhere(reviewId) {
5068
+ const pid = this.lease(reviewId)?.pid;
5069
+ return pid ? `Whiteboard process ${pid} is managing this review's language workspaces. Quit that Whiteboard Desktop or run \`whiteboard server stop\`, then retry.` : "Another Whiteboard process was managing this review's language workspaces. Retry.";
3110
5070
  }
3111
5071
  /** Takes the review's lease unless another live Desktop holds it. */
3112
5072
  claim(reviewId) {
@@ -3114,7 +5074,7 @@ var ReviewWorkspaces = class {
3114
5074
  this.db.exec("BEGIN IMMEDIATE");
3115
5075
  try {
3116
5076
  const free = !this.leasedElsewhere(reviewId);
3117
- if (free) this.db.prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?)").run(reviewId, this.ownerId, process.pid);
5077
+ if (free) this.db.prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?,?)").run(reviewId, this.ownerId, process.pid, processStartIdentity(process.pid));
3118
5078
  this.db.exec("COMMIT");
3119
5079
  return free;
3120
5080
  } catch (error) {
@@ -3180,7 +5140,7 @@ var ReviewWorkspaces = class {
3180
5140
  rootPath: null,
3181
5141
  generation: "",
3182
5142
  state: "pending",
3183
- log: OWNED_ELSEWHERE
5143
+ log: this.ownedElsewhere(reviewId)
3184
5144
  });
3185
5145
  }
3186
5146
  const request = this.acquire(id, reviewId, pins, side, retryFailed).finally(() => this.requests.delete(id));
@@ -3292,7 +5252,7 @@ var ReviewWorkspaces = class {
3292
5252
  const environment = this.get(id);
3293
5253
  if (!environment || environment.reviewId !== reviewId) throw new ReviewInputError("Language environment not found.", 404);
3294
5254
  if (this.jobs.has(id)) return this.status(environment);
3295
- if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
5255
+ if (!this.claim(reviewId)) throw new ReviewInputError(this.ownedElsewhere(reviewId), 409);
3296
5256
  if (environment.state === "cleanup-failed") {
3297
5257
  this.collect();
3298
5258
  return this.status(environment);
@@ -3361,7 +5321,6 @@ var ReviewWorkspaces = class {
3361
5321
  };
3362
5322
  //#endregion
3363
5323
  //#region src/review-api/worktree-events.ts
3364
- const exec$1 = promisify(execFile);
3365
5324
  const normalize = (name) => name.replaceAll("\\", "/").normalize("NFC");
3366
5325
  const parent = (file) => file.slice(0, Math.max(0, file.lastIndexOf("/")));
3367
5326
  /** Directories that hold tracked files, the root as "". */
@@ -3384,7 +5343,7 @@ function atTrackedEdge(name, tracked) {
3384
5343
  /** Wholly ignored directories, so watchers can skip them. */
3385
5344
  async function ignoredDirectories(rootPath, gitDirectory) {
3386
5345
  try {
3387
- const { stdout } = await exec$1("git", [
5346
+ const { stdout } = await runVcs("git", [
3388
5347
  "-C",
3389
5348
  rootPath,
3390
5349
  ...gitDirectory ? [
@@ -3423,7 +5382,7 @@ function gitEventMatters(name, prefix) {
3423
5382
  /** Where this checkout's git directory sits in the common one: "" for the main checkout. */
3424
5383
  async function gitDirectoryPrefix(rootPath, common) {
3425
5384
  try {
3426
- const { stdout } = await exec$1("git", [
5385
+ const { stdout } = await runVcs("git", [
3427
5386
  "-C",
3428
5387
  rootPath,
3429
5388
  "rev-parse",
@@ -3439,7 +5398,6 @@ async function gitDirectoryPrefix(rootPath, common) {
3439
5398
  //#endregion
3440
5399
  //#region src/review-api/worktree-source.ts
3441
5400
  const { lstat: lstat$1, readFile: readFile$1, readlink, realpath: realpath$1 } = checkoutFs;
3442
- const exec = promisify(execFile);
3443
5401
  const EMPTY_SOURCE = "empty-worktree-baseline";
3444
5402
  /** Never follow a working-copy link outside the selected checkout. */
3445
5403
  async function localSourcePath(root, file) {
@@ -3454,7 +5412,7 @@ async function localSourcePath(root, file) {
3454
5412
  async function workingFiles(vcs) {
3455
5413
  const gitDirectory = vcs.kind === "jj" ? await gitCommonDir(vcs.rootPath) : void 0;
3456
5414
  if (vcs.kind === "jj" && !gitDirectory) throw new ReviewInputError("Working source requires a Git-backed jj repository.");
3457
- const { stdout } = await exec("git", [
5415
+ const { stdout } = await runVcs("git", [
3458
5416
  "-C",
3459
5417
  vcs.rootPath,
3460
5418
  ...gitDirectory ? [
@@ -3490,7 +5448,7 @@ async function workingFiles(vcs) {
3490
5448
  async function untrackedFileCount(vcs) {
3491
5449
  if (vcs.kind !== "git") return void 0;
3492
5450
  try {
3493
- const { stdout } = await exec("git", [
5451
+ const { stdout } = await runVcs("git", [
3494
5452
  "-C",
3495
5453
  vcs.rootPath,
3496
5454
  "ls-files",
@@ -3570,6 +5528,10 @@ function sliceRange(file, source) {
3570
5528
  text: inputError(() => sliceSourceRange(file.text, source))
3571
5529
  };
3572
5530
  }
5531
+ /** How long a PR's state is shown before it is read again. */
5532
+ const PULL_REQUEST_STATE_TTL_MS = 3e5;
5533
+ /** How many PR states are read from GitHub at once. */
5534
+ const PULL_REQUEST_STATE_READS = 2;
3573
5535
  /** Local source/resource boundary, including Desktop-only local language context. */
3574
5536
  var LocalReviewData = class {
3575
5537
  store;
@@ -3647,7 +5609,7 @@ var LocalReviewData = class {
3647
5609
  };
3648
5610
  }
3649
5611
  if (commit && !anchor) await this.comparison(await this.documentPins(snapshot), commit);
3650
- const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
5612
+ const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId ?? (snapshot.target?.kind === "project" ? snapshot.target.repositoryId : void 0);
3651
5613
  if (!repositoryId) throw new ReviewInputError("This document has no source pins of its own.", 409);
3652
5614
  const unavailable = {
3653
5615
  rootPath: null,
@@ -3702,7 +5664,7 @@ var LocalReviewData = class {
3702
5664
  const context = await resolveRepoContext(repository);
3703
5665
  if (!rootPath || !context) throw new ReviewInputError("Could not open the selected source checkout.", 409);
3704
5666
  if (!live) {
3705
- const { stdout } = await promisify(execFile)("git", [
5667
+ const { stdout } = await runVcs("git", [
3706
5668
  "-C",
3707
5669
  rootPath,
3708
5670
  "status",
@@ -3717,7 +5679,7 @@ var LocalReviewData = class {
3717
5679
  const current = await readWorkspace(workspacePath);
3718
5680
  if (current !== null) {
3719
5681
  const previous = current ?? await readWorkspace(path.join(workspaceDirectory, `${path.basename(repository)}.code-workspace`));
3720
- const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
5682
+ const title = `${boardTitle(snapshot.title)} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
3721
5683
  const workspace = previous ?? {
3722
5684
  folders: [],
3723
5685
  settings: { "files.readonlyInclude": { "**/*": true } }
@@ -3754,12 +5716,25 @@ var LocalReviewData = class {
3754
5716
  rootPath
3755
5717
  };
3756
5718
  }
3757
- /** A document read that needs default pins; 409 when the document has none. */
5719
+ /** A document read that needs default pins: its own, or for a plan its
5720
+ * repository's live checkout with base at head; 409 when it has neither. */
3758
5721
  async documentPins(snapshot) {
3759
- const pins = await this.sourcePins(snapshot);
5722
+ const pins = await this.sourcePins(snapshot) ?? (snapshot.target?.kind === "project" ? await this.livePins(snapshot.target.repositoryId) : void 0);
3760
5723
  if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
3761
5724
  return pins;
3762
5725
  }
5726
+ /** A checkout's current state, base at head: what a plan reads. */
5727
+ async livePins(repositoryId) {
5728
+ const vcs = await this.vcs(repositoryId);
5729
+ if (!vcs) throw unavailableCheckout();
5730
+ const { revision, commit } = await this.worktreeState(repositoryId, vcs);
5731
+ return {
5732
+ repositoryId,
5733
+ base: commit,
5734
+ head: commit,
5735
+ worktreeRevision: revision
5736
+ };
5737
+ }
3763
5738
  /** Pins a reference names itself, checked against the registered checkout. */
3764
5739
  async anchorSourcePins(anchor) {
3765
5740
  if (!existsSync(this.store.repositoryPath(anchor.repositoryId))) throw unavailableCheckout();
@@ -3769,7 +5744,10 @@ var LocalReviewData = class {
3769
5744
  * `snapshot` is a stored version. */
3770
5745
  async unavailableAnchors(snapshot) {
3771
5746
  const missing = /* @__PURE__ */ new Set();
3772
- for (const repositoryId of pinnedRepositoryIds(snapshot.document)) try {
5747
+ const repositoryIds = pinnedRepositoryIds(snapshot.document);
5748
+ const boardRepository = boardRepositoryId(snapshot);
5749
+ if (boardRepository) repositoryIds.add(boardRepository);
5750
+ for (const repositoryId of repositoryIds) try {
3773
5751
  if (!existsSync(this.store.repositoryPath(repositoryId))) missing.add(repositoryId);
3774
5752
  } catch (error) {
3775
5753
  if (!(error instanceof ReviewInputError)) throw error;
@@ -3778,7 +5756,10 @@ var LocalReviewData = class {
3778
5756
  if (!missing.size) return [];
3779
5757
  return this.storedReferences.get(snapshot).filter((ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId)).map((ref) => ref.id);
3780
5758
  }
3781
- storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, { tolerant: true }), { capacity: 512 });
5759
+ storedReferences = new VersionCache((snapshot) => sourceReferences(snapshot.document, {
5760
+ tolerant: true,
5761
+ repositoryId: boardRepositoryId(snapshot)
5762
+ }), { capacity: 512 });
3782
5763
  trackedFiles = /* @__PURE__ */ new Map();
3783
5764
  repositories = /* @__PURE__ */ new Map();
3784
5765
  readers = /* @__PURE__ */ new Map();
@@ -4000,7 +5981,10 @@ var LocalReviewData = class {
4000
5981
  staleSources: []
4001
5982
  };
4002
5983
  delete projected.sourceUnavailable;
4003
- for (const reference of sourceReferences(snapshot.document, { tolerant: true })) try {
5984
+ for (const reference of sourceReferences(snapshot.document, {
5985
+ tolerant: true,
5986
+ repositoryId: boardRepositoryId(snapshot)
5987
+ })) try {
4004
5988
  await this.quote(anchorPins(reference.source, pins), reference.source);
4005
5989
  } catch (error) {
4006
5990
  if (!(error instanceof ReviewInputError)) throw error;
@@ -4012,6 +5996,7 @@ var LocalReviewData = class {
4012
5996
  * for a document without default pins. */
4013
5997
  async sourcePins(snapshot) {
4014
5998
  if (snapshot.target?.kind === "worktree") return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
5999
+ if (snapshot.target?.kind === "project" && !existsSync(this.store.repositoryPath(snapshot.target.repositoryId))) throw unavailableCheckout();
4015
6000
  if (!snapshot.pins) return void 0;
4016
6001
  if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId))) throw unavailableCheckout();
4017
6002
  return snapshot.pins;
@@ -4021,14 +6006,13 @@ var LocalReviewData = class {
4021
6006
  const vcs = await this.vcs(pins.repositoryId);
4022
6007
  if (!vcs || vcs.kind !== "git") return void 0;
4023
6008
  try {
4024
- const { stdout } = await promisify(execFile)("git", [
6009
+ const { stdout } = await runVcs("git", [
4025
6010
  "-C",
4026
6011
  vcs.rootPath,
4027
6012
  "rev-parse",
4028
6013
  "--symbolic-full-name",
4029
6014
  "--verify",
4030
- "--end-of-options",
4031
- !headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : headRef
6015
+ !headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef) ? "HEAD" : { rev: headRef }
4032
6016
  ]);
4033
6017
  const ref = stdout.trim();
4034
6018
  if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) return void 0;
@@ -4041,6 +6025,7 @@ var LocalReviewData = class {
4041
6025
  /** `pinned` refreshes a stored target: a worktree base ref resolves to its
4042
6026
  * current fork point, and keeps the pinned one if the ref is gone. */
4043
6027
  async resolveTarget(target, pinned) {
6028
+ if (target.kind === "project") throw new ReviewInputError("A project target has no commits.");
4044
6029
  const vcs = await this.vcs(target.repositoryId);
4045
6030
  if (!vcs) throw unavailableCheckout();
4046
6031
  if (target.kind === "commits") {
@@ -4105,6 +6090,86 @@ var LocalReviewData = class {
4105
6090
  commit: fork.commit
4106
6091
  };
4107
6092
  }
6093
+ /** Each PR's state, by URL, as last read. */
6094
+ pullRequestStates = /* @__PURE__ */ new Map();
6095
+ pullRequestStateListeners = /* @__PURE__ */ new Set();
6096
+ pullRequestStateTimer;
6097
+ pullRequestStateQueue = [];
6098
+ pullRequestStateReads = 0;
6099
+ /** While GitHub rate-limits, no state is read. */
6100
+ pullRequestStatesPausedUntil = 0;
6101
+ /** Calls back when a PR's state changes. While anyone listens, every PR
6102
+ * still shown is read again once its state is a few minutes old. */
6103
+ subscribePullRequestStates(listener) {
6104
+ this.pullRequestStateListeners.add(listener);
6105
+ this.pullRequestStateTimer ??= setInterval(() => {
6106
+ for (const [url, entry] of this.pullRequestStates) if (Date.now() - entry.seenAt > 2 * PULL_REQUEST_STATE_TTL_MS) this.pullRequestStates.delete(url);
6107
+ else this.pullRequestState(url, { seen: false });
6108
+ this.readPullRequestStates();
6109
+ }, PULL_REQUEST_STATE_TTL_MS / 5);
6110
+ this.pullRequestStateTimer.unref?.();
6111
+ return () => {
6112
+ this.pullRequestStateListeners.delete(listener);
6113
+ if (!this.pullRequestStateListeners.size) {
6114
+ clearInterval(this.pullRequestStateTimer);
6115
+ this.pullRequestStateTimer = void 0;
6116
+ }
6117
+ };
6118
+ }
6119
+ /** A PR's state as last read from GitHub; a read older than a few minutes
6120
+ * queues another in the background. Undefined until the first read lands,
6121
+ * and a failed read keeps the last state. A merged or closed PR is not
6122
+ * read again. */
6123
+ pullRequestState(url, { seen = true } = {}) {
6124
+ if (!url || !this.options.pullRequestStates) return void 0;
6125
+ const entry = this.pullRequestStates.get(url) ?? {
6126
+ readAt: 0,
6127
+ seenAt: 0,
6128
+ reading: false
6129
+ };
6130
+ this.pullRequestStates.set(url, entry);
6131
+ if (seen) entry.seenAt = Date.now();
6132
+ if (!entry.reading && entry.state !== "merged" && entry.state !== "closed" && Date.now() - entry.readAt > PULL_REQUEST_STATE_TTL_MS) {
6133
+ entry.reading = true;
6134
+ this.pullRequestStateQueue.push(url);
6135
+ this.readPullRequestStates();
6136
+ }
6137
+ return entry.state;
6138
+ }
6139
+ /** Reads queued PR states a few at a time. */
6140
+ readPullRequestStates() {
6141
+ while (this.pullRequestStateReads < PULL_REQUEST_STATE_READS && this.pullRequestStateQueue.length && Date.now() >= this.pullRequestStatesPausedUntil) {
6142
+ const url = this.pullRequestStateQueue.shift();
6143
+ const entry = this.pullRequestStates.get(url);
6144
+ if (!entry) continue;
6145
+ this.pullRequestStateReads++;
6146
+ Promise.resolve().then(() => {
6147
+ const { provider, address } = resolvePullRequest(url);
6148
+ return provider.readPullRequest(address, this.options.pullRequests ?? defaultPullRequestDeps);
6149
+ }).then((pullRequest) => {
6150
+ if ((!pullRequest.state || pullRequest.state === entry.state) && pullRequest.baseRefName === entry.baseBranch && pullRequest.headBranch === entry.headBranch) return;
6151
+ entry.state = pullRequest.state ?? entry.state;
6152
+ entry.baseBranch = pullRequest.baseRefName;
6153
+ entry.headBranch = pullRequest.headBranch;
6154
+ for (const listener of this.pullRequestStateListeners) listener();
6155
+ }).catch((error) => {
6156
+ if (error instanceof GitHubRefusedRead) this.pullRequestStatesPausedUntil = error.retryAt;
6157
+ }).finally(() => {
6158
+ entry.readAt = Date.now();
6159
+ entry.reading = false;
6160
+ this.pullRequestStateReads--;
6161
+ this.readPullRequestStates();
6162
+ });
6163
+ }
6164
+ }
6165
+ /** The branch a PR merges into, as last read with its state. */
6166
+ pullRequestBaseBranch(url) {
6167
+ return this.pullRequestStates.get(url)?.baseBranch;
6168
+ }
6169
+ /** A PR's own branch, as last read with its state. */
6170
+ pullRequestHeadBranch(url) {
6171
+ return this.pullRequestStates.get(url)?.headBranch;
6172
+ }
4108
6173
  /** The PR's current comparison, fetched into a registered checkout of its repository. */
4109
6174
  async resolvePullRequest(url, repository) {
4110
6175
  const deps = this.options.pullRequests ?? defaultPullRequestDeps;
@@ -4145,9 +6210,8 @@ var LocalReviewData = class {
4145
6210
  }
4146
6211
  /** A registered checkout with a remote for host/owner/repo, and that remote. */
4147
6212
  async pullRequestCheckout(provider, address, repository, deps) {
4148
- const registered = this.store.repositories();
4149
- 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)];
4150
- if (repository.id && candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
6213
+ const candidates = this.store.repositories().filter((entry) => entry.id === repository.id);
6214
+ if (candidates.length === 0) throw new ReviewInputError("Repository is not registered.", 404);
4151
6215
  for (const { id } of candidates) {
4152
6216
  if (!existsSync(this.store.repositoryPath(id))) continue;
4153
6217
  const vcs = await this.vcs(id);
@@ -4164,7 +6228,7 @@ var LocalReviewData = class {
4164
6228
  }
4165
6229
  const { host, slug } = address;
4166
6230
  const name = host === "github.com" ? slug : `${host}/${slug}`;
4167
- throw new ReviewInputError(repository.id ? `That checkout has no remote for ${name}. Add one, or omit repositoryId.` : `No registered checkout has a remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`, 404);
6231
+ throw new ReviewInputError(`That checkout has no remote for ${name}. Name a checkout of ${name} as the target's repositoryPath.`, 404);
4168
6232
  }
4169
6233
  async resolvePins(repositoryId, base, head) {
4170
6234
  const vcs = await this.vcs(repositoryId);
@@ -4196,6 +6260,45 @@ var LocalReviewData = class {
4196
6260
  head
4197
6261
  };
4198
6262
  }
6263
+ /** What a commit is, for a quote read at it: its subject, author and
6264
+ * date, and how far behind the default branch's tip it is when both are
6265
+ * known. */
6266
+ async commitInfo(repositoryId, commit) {
6267
+ const vcs = await this.vcs(repositoryId);
6268
+ if (!vcs) throw unavailableCheckout();
6269
+ const git = async (...args) => (await runVcs("git", [
6270
+ "-C",
6271
+ vcs.rootPath,
6272
+ ...args
6273
+ ])).stdout.trim();
6274
+ let line;
6275
+ try {
6276
+ line = await git("log", "-1", "--format=%H%x00%s%x00%an%x00%aI", { rev: commit });
6277
+ } catch {
6278
+ throw new ReviewInputError("That commit is not in the repository.", 404);
6279
+ }
6280
+ const [full = commit, subject = "", author = "", authoredAt = ""] = line.split("\0");
6281
+ const branch = await vcs.defaultBranch().catch(() => null);
6282
+ const behind = branch ? await git("rev-list", "--count", { rev: `${full}..${branch.commit}` }).then((count) => ({
6283
+ count: Number(count),
6284
+ branch: branch.ref.replace(/^origin\//, "")
6285
+ })).catch(() => void 0) : void 0;
6286
+ return {
6287
+ commit: full,
6288
+ subject,
6289
+ author,
6290
+ authoredAt,
6291
+ ...behind && { behind }
6292
+ };
6293
+ }
6294
+ /** The full commit a commit ID or its prefix names in the repository,
6295
+ * or undefined when it names none. */
6296
+ async resolveCommit(repositoryId, commit) {
6297
+ const vcs = await this.vcs(repositoryId);
6298
+ if (!vcs) throw unavailableCheckout();
6299
+ const resolved = await vcs.resolveRevision(commit);
6300
+ return resolved?.commit.startsWith(commit.toLowerCase()) ? resolved.commit : void 0;
6301
+ }
4199
6302
  async validatePins(pins) {
4200
6303
  if (pins.worktreeRevision) {
4201
6304
  if (!await this.vcs(pins.repositoryId)) throw unavailableCheckout();
@@ -4558,6 +6661,7 @@ async function unwatch(entry) {
4558
6661
  /** Linux fails to unwatch a directory that is already gone. */
4559
6662
  const release = (subscription) => subscription.unsubscribe().catch(() => {});
4560
6663
  function openLocalReviewStore(databasePath, options = {}) {
6664
+ const { checkoutOwners, ...dataOptions } = options;
4561
6665
  const store = new ReviewStore(databasePath, {
4562
6666
  projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
4563
6667
  resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
@@ -4567,14 +6671,16 @@ function openLocalReviewStore(databasePath, options = {}) {
4567
6671
  sourcePins: (snapshot) => data.sourcePins(snapshot),
4568
6672
  unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
4569
6673
  validatePins: (pins) => data.validatePins(pins),
6674
+ resolveCommit: (repositoryId, commit) => data.resolveCommit(repositoryId, commit),
4570
6675
  filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
4571
6676
  validateSource: (pins, source, options) => data.validateSource(pins, source, options),
4572
- validateResource: (pins, block) => data.validateResource(pins, block)
4573
- });
6677
+ validateResource: (pins, block) => data.validateResource(pins, block),
6678
+ pullRequestBaseBranch: (url) => data.pullRequestBaseBranch(url)
6679
+ }, checkoutOwners);
4574
6680
  let data;
4575
6681
  try {
4576
6682
  data = new LocalReviewData(store, {
4577
- ...options,
6683
+ ...dataOptions,
4578
6684
  workspaceDatabase: `${databasePath}.workspaces`
4579
6685
  });
4580
6686
  } catch (error) {
@@ -4607,4 +6713,4 @@ async function readWorkspace(file) {
4607
6713
  }
4608
6714
  }
4609
6715
  //#endregion
4610
- export { inspectSnapshot as a, activityEndSchema as c, invalidateStructuralComparisons as d, diffrExecutable as f, commandSchema as i, activityUpdateSchema as l, uploadSchema as n, initializeReviewStoreSchema as o, diffrMissingError as p, SCRATCHPAD_ID as r, activityBeginSchema as s, openLocalReviewStore as t, pullRequestProvider as u };
6716
+ export { projectOfCheckout as a, activityBeginSchema as c, pullRequestProvider as d, textualRows as f, CheckoutGroups as g, diffrMissingError as h, inspectSnapshot as i, activityEndSchema as l, diffrExecutable as m, uploadSchema as n, groupCheckoutsAhead as o, invalidateStructuralComparisons as p, commandSchema as r, initializeReviewStoreSchema as s, openLocalReviewStore as t, activityUpdateSchema as u };