@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261020.130

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-CprZojXd.js → account-alias-CMyXCuuq.js} +13 -8
  2. package/dist/{agent-cli-CThN7Q59.js → agent-cli-CahKfDT0.js} +15 -10
  3. package/dist/agent-cli-CvYV2R6c.js +2 -0
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-D2RmE9NL.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BLhYKrNN.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-CFUU4VtN.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-k4K_Hvh1.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BUVSMGud.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-C7W6VA94.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-B0f4EJ7v.js} +253 -47
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-BbaX-dGh.js} +6 -4
  14. package/dist/{headless-host-Cog6SpS9.js → headless-host-C7xYMe74.js} +513 -140
  15. package/dist/{headless-host-CVBOQyRS.js → headless-host-cYdlRYPB.js} +1 -1
  16. package/dist/lens-selection-CAtsGaym.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-DrNSaiS5.js} +2416 -320
  18. package/dist/{mcp-D1hziANa.js → mcp-yXnjUkOV.js} +6 -10
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DokFw782.js → profile-BlqZlCPZ.js} +20 -8
  21. package/dist/profile-CGxNfeYQ.js +2 -0
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-CZTrtWIk.js} +4 -4
  23. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-BiPzol99.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-CFTkoioA.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin--8lLCclv.js} +55 -14
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-KA2_mOeo.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-B2JniTIs.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-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-CRXG48Uz.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-CyYCvVHx.js} +90 -17
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B_BijGHF.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-D6MlnGv7.js} +7 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-_makh_Sr.js} +10 -3
  46. package/instructions/authoring.md +18 -5
  47. package/package.json +6 -6
  48. package/src/ask/agents.ts +13 -1
  49. package/src/ask/checkout-files.ts +2 -5
  50. package/src/ask/thread-state.ts +4 -0
  51. package/src/ask/thread.ts +16 -0
  52. package/src/ask/threads.ts +24 -3
  53. package/src/cli-install.ts +13 -2
  54. package/src/cli-runner.ts +4 -1
  55. package/src/cli.ts +4 -1
  56. package/src/diff-selection-migration.ts +48 -34
  57. package/src/heading-anchors.ts +42 -0
  58. package/src/lens-selection.ts +38 -2
  59. package/src/review-api/README.md +52 -13
  60. package/src/review-api/activity.ts +1 -4
  61. package/src/review-api/agent-cli.ts +9 -1
  62. package/src/review-api/anchor-quotes.ts +14 -7
  63. package/src/review-api/ask-history.ts +137 -51
  64. package/src/review-api/authoring-tools.ts +45 -30
  65. package/src/review-api/blocks/definition.ts +1 -1
  66. package/src/review-api/blocks/markdown.ts +2 -2
  67. package/src/review-api/board-order.ts +144 -0
  68. package/src/review-api/checkout-groups.ts +560 -0
  69. package/src/review-api/document-headings.ts +58 -27
  70. package/src/review-api/document-text.ts +31 -2
  71. package/src/review-api/document.ts +281 -34
  72. package/src/review-api/foreign-references.ts +328 -0
  73. package/src/review-api/github-provider.ts +51 -13
  74. package/src/review-api/http.ts +702 -123
  75. package/src/review-api/instructions.ts +0 -19
  76. package/src/review-api/link-preview.ts +113 -0
  77. package/src/review-api/local-data.ts +310 -46
  78. package/src/review-api/mcp.ts +4 -12
  79. package/src/review-api/onboarding.ts +1 -1
  80. package/src/review-api/profile.ts +37 -6
  81. package/src/review-api/public-tools.ts +24 -2
  82. package/src/review-api/pull-request.ts +82 -49
  83. package/src/review-api/request-origin.ts +10 -0
  84. package/src/review-api/review-progress.ts +61 -27
  85. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  86. package/src/review-api/store-migrations.ts +357 -0
  87. package/src/review-api/store-schema.ts +38 -29
  88. package/src/review-api/store.ts +2144 -480
  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 +44 -14
  111. package/dist/agent-cli-C6SPSII0.js +0 -2
  112. package/dist/profile-CYPmvBoA.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,8 +1,6 @@
1
- import { execFile } from "node:child_process";
2
1
  import { isAbsolute, relative } from "node:path";
3
- import { promisify } from "node:util";
4
2
 
5
- const exec = promisify(execFile);
3
+ import { runVcs } from "@dev.fast/local-vcs";
6
4
 
7
5
  const normalize = (name: string) => name.replaceAll("\\", "/").normalize("NFC");
8
6
 
@@ -53,7 +51,7 @@ export async function ignoredDirectories(
53
51
  gitDirectory?: string,
54
52
  ): Promise<string[]> {
55
53
  try {
56
- const { stdout } = await exec(
54
+ const { stdout } = await runVcs(
57
55
  "git",
58
56
  [
59
57
  "-C",
@@ -105,7 +103,7 @@ export async function gitDirectoryPrefix(
105
103
  common: string,
106
104
  ): Promise<string | null> {
107
105
  try {
108
- const { stdout } = await exec("git", [
106
+ const { stdout } = await runVcs("git", [
109
107
  "-C",
110
108
  rootPath,
111
109
  "rev-parse",
@@ -1,9 +1,7 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash } from "node:crypto";
3
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
- import { promisify } from "node:util";
5
3
 
6
- import { type LocalVcs, gitCommonDir } from "@dev.fast/local-vcs";
4
+ import { type LocalVcs, gitCommonDir, runVcs } from "@dev.fast/local-vcs";
7
5
  import { isMissingFileError } from "@review/fs-utils.js";
8
6
 
9
7
  import { checkoutFs } from "./checkout-fs.js";
@@ -11,8 +9,6 @@ import { ReviewInputError } from "./document.js";
11
9
 
12
10
  const { lstat, readFile, readlink, realpath } = checkoutFs;
13
11
 
14
- const exec = promisify(execFile);
15
-
16
12
  export const EMPTY_SOURCE = "empty-worktree-baseline";
17
13
 
18
14
  /** Never follow a working-copy link outside the selected checkout. */
@@ -49,7 +45,7 @@ export async function workingFiles(vcs: LocalVcs): Promise<string[]> {
49
45
  "Working source requires a Git-backed jj repository.",
50
46
  );
51
47
 
52
- const { stdout } = await exec(
48
+ const { stdout } = await runVcs(
53
49
  "git",
54
50
  [
55
51
  "-C",
@@ -100,7 +96,7 @@ export async function untrackedFileCount(
100
96
  if (vcs.kind !== "git") return undefined;
101
97
 
102
98
  try {
103
- const { stdout } = await exec(
99
+ const { stdout } = await runVcs(
104
100
  "git",
105
101
  [
106
102
  "-C",
@@ -318,3 +318,75 @@ it("compares a staged file in an unborn repository with empty source", async ()
318
318
  rmSync(root, { recursive: true, force: true });
319
319
  }
320
320
  }, 30_000);
321
+
322
+ it("shows a peek on an untracked file as head lines", async () => {
323
+ const root = mkdtempSync(path.join(tmpdir(), "worktree-untracked-"));
324
+ const repository = path.join(root, "repository");
325
+ mkdirSync(repository);
326
+
327
+ const git = (...args: string[]) =>
328
+ execFileSync("git", ["-C", repository, ...args], {
329
+ encoding: "utf8",
330
+ }).trim();
331
+
332
+ git("init", "-q", "-b", "main");
333
+ writeFileSync(path.join(repository, "value.ts"), "export const value = 1;\n");
334
+ git("add", "value.ts");
335
+ git(
336
+ "-c",
337
+ "user.name=Test",
338
+ "-c",
339
+ "user.email=test@example.invalid",
340
+ "commit",
341
+ "-qm",
342
+ "Base",
343
+ );
344
+ writeFileSync(path.join(repository, "new.ts"), "export const a = 1;\n");
345
+
346
+ const local = openLocalReviewStore(path.join(root, "reviews.db"));
347
+
348
+ const selection = {
349
+ file: "new.ts",
350
+ start: { side: "head" as const, line: 1 },
351
+ end: { side: "head" as const, line: 1 },
352
+ };
353
+
354
+ try {
355
+ const { id } = await local.data.register(repository);
356
+
357
+ const { reviewId } = await local.store.execute({
358
+ operation: {
359
+ type: "create",
360
+ title: "Live",
361
+ target: { kind: "worktree", repositoryId: id },
362
+ },
363
+ });
364
+
365
+ await local.store.execute({
366
+ operation: {
367
+ type: "edit",
368
+ reviewId,
369
+ edit: {
370
+ type: "insert",
371
+ content: { type: "code_peek", source: formatAnchor(selection) },
372
+ },
373
+ },
374
+ });
375
+
376
+ const progress = await (
377
+ await createReviewApi(local.store, local.data).request(
378
+ `/${reviewId}/progress`,
379
+ )
380
+ ).json();
381
+
382
+ expect(progress.untrackedFiles).toBe(1);
383
+ expect(progress.unavailableSelections).toEqual({});
384
+ expect(progress.resolvedSelections[selectionKey(selection)]).toEqual([
385
+ { file: "new.ts", side: "head", fromLine: 1, toLine: 1 },
386
+ ]);
387
+ } finally {
388
+ await local.data.close();
389
+ await local.store.close();
390
+ rmSync(root, { recursive: true, force: true });
391
+ }
392
+ });
@@ -1,15 +1,10 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- import { defaultBranch, git } from "@dev.fast/local-vcs";
1
+ import { defaultBranch, git, runVcs } from "@dev.fast/local-vcs";
5
2
  import type {
6
3
  ReviewApiSummary,
7
4
  ReviewStackLayer,
8
5
  } from "@dev.fast/review-protocol";
9
6
  import { z } from "zod";
10
7
 
11
- const execFileAsync = promisify(execFile);
12
-
13
8
  const GitHubStacksSchema = z.array(
14
9
  z.object({
15
10
  pull_requests: z.array(
@@ -27,7 +22,7 @@ type Subject = Pick<ReviewApiSummary, "origin"> & { reviewId?: string };
27
22
 
28
23
  type Candidate = Pick<
29
24
  ReviewApiSummary,
30
- "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "repositoryGroup"
25
+ "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "project"
31
26
  >;
32
27
 
33
28
  interface StackEntry {
@@ -140,7 +135,7 @@ async function localStack(
140
135
  repositoryPath: string,
141
136
  ): Promise<StackEntry[]> {
142
137
  const group = reviews.find((review) => review.reviewId === subject.reviewId)
143
- ?.repositoryGroup?.key;
138
+ ?.project?.id;
144
139
 
145
140
  const current = subject.origin?.branch;
146
141
 
@@ -152,9 +147,7 @@ async function localStack(
152
147
 
153
148
  const name = (ref: string) => ref.replace(/^origin\//, "");
154
149
 
155
- const inGroup = reviews.filter(
156
- (review) => review.repositoryGroup?.key === group,
157
- );
150
+ const inGroup = reviews.filter((review) => review.project?.id === group);
158
151
 
159
152
  const latest = (field: "branch" | "baseBranch", branch: string) => {
160
153
  const matches = inGroup.filter(
@@ -208,9 +201,13 @@ async function localStack(
208
201
  async function forkPoint(repositoryPath: string, main: string, branch: string) {
209
202
  const ref = `refs/heads/${branch}`;
210
203
 
211
- const fork = await git(repositoryPath, ["merge-base", main, ref], {
212
- allowFailure: true,
213
- });
204
+ const fork = await git(
205
+ repositoryPath,
206
+ ["merge-base", { rev: main }, { rev: ref }],
207
+ {
208
+ allowFailure: true,
209
+ },
210
+ );
214
211
 
215
212
  if (!fork.ok) return null;
216
213
 
@@ -218,7 +215,7 @@ async function forkPoint(repositoryPath: string, main: string, branch: string) {
218
215
 
219
216
  const log = await git(
220
217
  repositoryPath,
221
- ["log", "--reverse", "--format=%s", `${base}..${ref}`],
218
+ ["log", "--reverse", "--format=%s", { rev: `${base}..${ref}` }],
222
219
  { allowFailure: true },
223
220
  );
224
221
 
@@ -229,15 +226,10 @@ async function defaultRunGitHubApi(
229
226
  host: string,
230
227
  endpoint: string,
231
228
  ): Promise<string> {
232
- const { stdout } = await execFileAsync(
229
+ const { stdout } = await runVcs(
233
230
  "gh",
234
- ["api", "--hostname", host, endpoint],
235
- {
236
- env: { ...process.env, GH_PROMPT_DISABLED: "1" },
237
- encoding: "utf8",
238
- timeout: 10_000,
239
- maxBuffer: 2 * 1024 * 1024,
240
- },
231
+ ["api", "--hostname", { value: host }, { value: endpoint }],
232
+ { timeout: 10_000, maxBuffer: 2 * 1024 * 1024 },
241
233
  );
242
234
 
243
235
  return stdout;
package/src/runtime.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { spawn } from "node:child_process";
2
1
  import path from "node:path";
3
2
 
3
+ import { runVcs } from "@dev.fast/local-vcs";
4
+
4
5
  export async function resolveReviewRoot(
5
6
  cwd: string,
6
7
  execFile: typeof execFilePromise = execFilePromise,
@@ -39,36 +40,9 @@ export async function resolveReviewRoot(
39
40
  }
40
41
 
41
42
  function execFilePromise(
42
- file: string,
43
+ file: "git" | "jj",
43
44
  args: string[],
44
45
  options: { cwd?: string } = {},
45
46
  ): Promise<{ stdout: string }> {
46
- return execFileUntraced(file, args, options);
47
- }
48
-
49
- function execFileUntraced(
50
- file: string,
51
- args: string[],
52
- options: { cwd?: string } = {},
53
- ): Promise<{ stdout: string }> {
54
- return new Promise((resolve, reject) => {
55
- const child = spawn(file, args, {
56
- cwd: options.cwd,
57
- stdio: ["ignore", "pipe", "pipe"],
58
- });
59
-
60
- let stdout = "";
61
- let stderr = "";
62
- child.stdout?.on("data", (chunk) => {
63
- stdout += String(chunk);
64
- });
65
- child.stderr?.on("data", (chunk) => {
66
- stderr += String(chunk);
67
- });
68
- child.on("error", reject);
69
- child.on("exit", (code) => {
70
- if (code === 0) resolve({ stdout });
71
- else reject(new Error(stderr.trim() || `${file} exited with ${code}`));
72
- });
73
- });
47
+ return runVcs(file, args, options);
74
48
  }
@@ -84,6 +84,8 @@ export async function runDesktopHost(
84
84
  const { local, shared, server } = await takeOverServerHome(home, async () => {
85
85
  const local = await openReviewProfile(home, {
86
86
  manageWorkspaces: true,
87
+ // Desktop shows each PR's state beside its board.
88
+ pullRequestStates: true,
87
89
  });
88
90
 
89
91
  // JSON is the sole user-review store. A failure is surfaced, never replaced
@@ -197,9 +199,12 @@ function requiredPositiveInteger(
197
199
 
198
200
  if (process.env.DEV_FAST_REVIEW_DESKTOP_HOST_AUTOSTART !== "0") {
199
201
  void runDesktopHost().catch((error) => {
202
+ // Exit now: a host that couldn't start (a store that couldn't be
203
+ // upgraded) must not linger with timers alive, or Desktop waits out its
204
+ // ready timeout instead of reporting why.
200
205
  process.stderr.write(
201
206
  `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
207
+ () => process.exit(1),
202
208
  );
203
- process.exitCode = 1;
204
209
  });
205
210
  }
@@ -47,10 +47,6 @@ import {
47
47
  reviewInstanceDiscoveryPath,
48
48
  reviewLegacyDiscoveryPath,
49
49
  } from "@review/review-home-paths";
50
- import {
51
- readScratchpadEnabled,
52
- writeScratchpadEnabled,
53
- } from "@review/review-preferences";
54
50
  import {
55
51
  ReviewTelemetry,
56
52
  type ReviewTelemetryContext,
@@ -176,10 +172,6 @@ export function createGlobalReviewServer(
176
172
  let closing = false;
177
173
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
178
174
 
179
- // The scratchpad preference, read once at listen and kept current by the
180
- // settings endpoint below: this server is the only writer while it runs.
181
- let scratchpadEnabled = false;
182
-
183
175
  const discovery: ReviewDesktopDiscovery = {
184
176
  version: REVIEW_DESKTOP_DISCOVERY_VERSION,
185
177
  instanceId,
@@ -212,7 +204,6 @@ export function createGlobalReviewServer(
212
204
  relay,
213
205
  token,
214
206
  instanceId,
215
- scratchpad: () => scratchpadEnabled,
216
207
  status: () => {
217
208
  const { key, channel, checkout, appVersion, cliVersion, instanceId } =
218
209
  discovery;
@@ -265,25 +256,6 @@ export function createGlobalReviewServer(
265
256
  app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
266
257
  app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
267
258
  app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
268
- app.get("/preferences/scratchpad", () =>
269
- serverJson(200, { enabled: scratchpadEnabled }),
270
- );
271
- app.put("/preferences/scratchpad", async (context) => {
272
- const request = z
273
- .object({ enabled: z.boolean() })
274
- .safeParse(await readBoundedRequestJson(context.req.raw));
275
-
276
- if (!request.success)
277
- throw new ReviewServerError("enabled must be a boolean.", 400);
278
-
279
- scratchpadEnabled = await writeScratchpadEnabled(request.data.enabled);
280
-
281
- // Home watches the catalog; the pad appears or goes without a store write.
282
- if (scratchpadEnabled) await reviewStore.ensureScratchpad();
283
- reviewStore.invalidateCatalog();
284
-
285
- return serverJson(200, { enabled: scratchpadEnabled });
286
- });
287
259
  app.post("/app/focus", async () => {
288
260
  const result = await relay.dispatch({
289
261
  name: "focusWindow",
@@ -525,7 +497,6 @@ export function createGlobalReviewServer(
525
497
  store: reviewStore,
526
498
  data: input.reviewData,
527
499
  });
528
- scratchpadEnabled = await readScratchpadEnabled();
529
500
  boundPort = await listen(httpServer, input.port);
530
501
  discovery.url = urlForBoundPort();
531
502
 
@@ -583,8 +554,7 @@ export function sessionStartedSource(
583
554
  try {
584
555
  const snapshot = reviewStore.read(reviewUuid);
585
556
 
586
- const sourceKind =
587
- snapshot.kind === "scratchpad" ? snapshot.kind : snapshot.target?.kind;
557
+ const sourceKind = snapshot.target?.kind;
588
558
 
589
559
  const pullRequestUrl = snapshot.origin?.pullRequestUrl;
590
560
 
@@ -221,8 +221,6 @@ async function serve(input: HeadlessServerInput) {
221
221
  token: discovery.token,
222
222
  instanceId: discovery.instanceId,
223
223
  softwareMapEnabled: input.softwareMapEnabled,
224
- // The scratchpad is the laptop's alone, even with a Desktop attached.
225
- scratchpad: () => false,
226
224
  status: () => ({ key: "headless", home: input.stateDir }),
227
225
  ask: {
228
226
  tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
@@ -7,6 +7,7 @@ import {
7
7
  parseJsonText,
8
8
  } from "@dev.fast/review-protocol";
9
9
  import { type HttpBindings, getRequestListener } from "@hono/node-server";
10
+ import { REVIEW_PERSON_HEADER } from "@review/review-api/request-origin";
10
11
  import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
11
12
  import type { Hono } from "hono";
12
13
  import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -73,7 +74,7 @@ export function applyCorsHeaders(
73
74
 
74
75
  response.headers.set(
75
76
  "access-control-allow-headers",
76
- `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`,
77
+ `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`,
77
78
  );
78
79
  response.headers.set(
79
80
  "access-control-allow-methods",
@@ -13,14 +13,13 @@ import { devReviewHome } from "@review/review-home-paths";
13
13
  */
14
14
  export async function removeLegacyTutorial(input: {
15
15
  store: ReviewStore;
16
- data: LocalReviewData;
16
+ data: Pick<LocalReviewData, "forgetRepository">;
17
17
  root?: string;
18
18
  }): Promise<void> {
19
19
  const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
20
20
 
21
21
  try {
22
- for (const reviewId of input.store.legacyTutorialIds())
23
- await input.store.execute({ operation: { type: "delete", reviewId } });
22
+ await input.store.removeLegacyTutorials();
24
23
 
25
24
  for (const repository of input.store.repositories()) {
26
25
  const relative = path.relative(tutorialRoot, repository.path);
@@ -32,11 +32,9 @@ function decorate(
32
32
 
33
33
  return {
34
34
  ...entry,
35
- ...(entry.repositoryGroup && {
36
- repositoryGroup: {
37
- key: `${alias}:${entry.repositoryGroup.key}`,
38
- label: entry.repositoryGroup.label,
39
- },
35
+ // A project on another machine is that machine's.
36
+ ...(entry.project && {
37
+ project: { id: `${alias}:${entry.project.id}`, name: entry.project.name },
40
38
  }),
41
39
  host: alias,
42
40
  hostState,
@@ -16,7 +16,7 @@ const listEntrySchema = z.looseObject({
16
16
  repositoryName: z.string(),
17
17
  viewedAt: z.string().nullable(),
18
18
  trashedAt: z.string().nullable(),
19
- repositoryGroup: z.object({ key: z.string(), label: z.string() }).optional(),
19
+ project: z.object({ id: z.string(), name: z.string() }).optional(),
20
20
  });
21
21
 
22
22
  export const listEntriesSchema = z.array(z.unknown()).transform(
@@ -34,12 +34,12 @@ export function reviewLifecycleTelemetry(
34
34
  );
35
35
 
36
36
  return {
37
- onReviewCreated: ({ reviewId, kind, blocks, via, agentKind }) => {
37
+ onReviewCreated: ({ reviewId, blocks, via, agentKind }) => {
38
38
  if (reported.has(reviewId)) return;
39
39
  reported.add(reviewId);
40
40
 
41
41
  if (via === "api" || via === "mcp") authoring.set(reviewId, agentKind);
42
- const properties: PostHogCaptureProperties = { kind, blocks, via };
42
+ const properties: PostHogCaptureProperties = { blocks, via };
43
43
 
44
44
  if (agentKind) properties.agent_kind = agentKind;
45
45
  capture("review_review_created", properties, reviewId);
@@ -132,7 +132,6 @@ export interface WhiteboardCoreInput {
132
132
  token: string;
133
133
  instanceId: string;
134
134
  softwareMapEnabled?: boolean;
135
- scratchpad: () => boolean;
136
135
  status: () => JsonObject;
137
136
  hooks?: ReviewApiHooks;
138
137
  ask?: {
@@ -192,7 +191,6 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
192
191
  callbacks.open,
193
192
  shared,
194
193
  callbacks.capabilities,
195
- input.scratchpad,
196
194
  () => traceMachineEnabled(),
197
195
  input.status,
198
196
  input.hooks,
@@ -236,9 +234,7 @@ export function relayReviewCallbacks(
236
234
 
237
235
  return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
238
236
  },
239
- async capabilities(): Promise<
240
- Omit<AuthoringCapabilities, "scratchpadEnabled">
241
- > {
237
+ async capabilities(): Promise<AuthoringCapabilities> {
242
238
  if (!relay.attached)
243
239
  return { desktopAvailable: false, softwareMapEnabled };
244
240
 
@@ -1,3 +1,8 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { git, gitCommonDir } from "@dev.fast/local-vcs";
1
6
  import type { StructuralDiffEvent } from "@dev.fast/review-protocol";
2
7
 
3
8
  import {
@@ -18,6 +23,7 @@ export class StructuralComparisons {
18
23
 
19
24
  const key = JSON.stringify([
20
25
  configGeneration,
26
+ await classificationStamp(input.repositoryPath),
21
27
  input.repositoryPath,
22
28
  input.comparison,
23
29
  input.paths,
@@ -66,6 +72,47 @@ export class StructuralComparisons {
66
72
 
67
73
  let configGeneration = 0;
68
74
 
75
+ const classificationFiles = new Map<string, Promise<string[]>>();
76
+
77
+ /** Git attribute and diffr config files that diffr's classifier reads. */
78
+ function classificationInputs(root: string): Promise<string[]> {
79
+ let files = classificationFiles.get(root);
80
+
81
+ if (!files) {
82
+ const config =
83
+ process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
84
+
85
+ const diffrDirectory =
86
+ process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
87
+
88
+ files = Promise.all([
89
+ gitCommonDir(root),
90
+ git(root, ["config", "--path", "core.attributesFile"], {
91
+ allowFailure: true,
92
+ }),
93
+ ]).then(([common, user]) => [
94
+ ...(common ? [path.join(common, "info", "attributes")] : []),
95
+ path.join(root, ".gitattributes"),
96
+ user.stdout.trim() || path.join(config, "git", "attributes"),
97
+ path.join(diffrDirectory, "config.toml"),
98
+ ]);
99
+ classificationFiles.set(root, files);
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ async function classificationStamp(root: string) {
106
+ return Promise.all(
107
+ (await classificationInputs(root)).map((file) =>
108
+ stat(file).then(
109
+ ({ mtimeMs, size }) => [mtimeMs, size],
110
+ () => null,
111
+ ),
112
+ ),
113
+ );
114
+ }
115
+
69
116
  export function invalidateStructuralComparisons(): void {
70
117
  configGeneration++;
71
118
  }
@@ -14,10 +14,11 @@ import { lensSelections } from "@review/review-api/diff-lenses.js";
14
14
  import { ReviewInputError } from "@review/review-api/document.js";
15
15
  import {
16
16
  anchorPins,
17
+ boardRepositoryId,
17
18
  checkReferences,
18
19
  documentSchema,
19
20
  elements,
20
- hasCodeReferences,
21
+ readsAtBoardPins,
21
22
  resourceReference,
22
23
  resourceReferences,
23
24
  sourceReferences,
@@ -46,13 +47,33 @@ export function digestBytes(bytes: Uint8Array): string {
46
47
  export function validateShareSources(
47
48
  snapshot: Pick<Snapshot, "pins" | "document" | "lenses">,
48
49
  ) {
49
- if (!snapshot.pins && hasCodeReferences(snapshot))
50
+ if (!snapshot.pins && readsAtBoardPins(snapshot))
50
51
  throw new ReviewInputError(
51
52
  "Reviews with code references need source pins before sharing.",
52
53
  409,
53
54
  );
54
55
  }
55
56
 
57
+ /** The commits a board's references read at besides its base and head. A
58
+ * recipient fetches them too. A shared board names no repository of its
59
+ * own, so one stands in for its commit links. */
60
+ export function referenceCommits(
61
+ snapshot: Pick<Snapshot, "pins" | "document" | "target">,
62
+ repositoryId = boardRepositoryId(snapshot) ?? "shared",
63
+ ): string[] {
64
+ const own = new Set([snapshot.pins?.base, snapshot.pins?.head]);
65
+ const commits = new Set<string>();
66
+
67
+ for (const { source } of sourceReferences(snapshot.document, {
68
+ tolerant: true,
69
+ repositoryId,
70
+ }))
71
+ for (const commit of [source.pins?.base, source.pins?.head])
72
+ if (commit && !own.has(commit)) commits.add(commit);
73
+
74
+ return [...commits].sort();
75
+ }
76
+
56
77
  /** Freeze one stored version before any asynchronous source reads. */
57
78
  export async function exportShare(input: {
58
79
  store: ShareExportStore;
@@ -61,16 +82,24 @@ export async function exportShare(input: {
61
82
  version?: number;
62
83
  repository?: ShareManifest["repository"];
63
84
  }): Promise<ShareBundle> {
64
- // A share is the finished document: what edit last touched it stays home.
85
+ const stored = structuredClone(
86
+ input.store.read(input.reviewId, input.version),
87
+ );
88
+
89
+ // Commit links read from the board's repository, which a plan names only
90
+ // in its target.
91
+ const repositoryId = boardRepositoryId(stored);
92
+
93
+ // Local-only fields stay home; the target names a local checkout.
65
94
  const {
66
95
  target,
67
- kind: _kind,
68
96
  staleSources: _staleSources,
69
97
  sourceUnavailable: _sourceUnavailable,
70
98
  lastEdit: _lastEdit,
71
99
  trashedAt: _trashedAt,
100
+ personEdit: _personEdit,
72
101
  ...snapshot
73
- } = structuredClone(input.store.read(input.reviewId, input.version));
102
+ } = stored;
74
103
 
75
104
  if (target?.kind === "worktree")
76
105
  throw new ReviewInputError("Pin this review to commits before sharing it.");
@@ -102,7 +131,7 @@ export async function exportShare(input: {
102
131
 
103
132
  // Lens ranges travel with the snapshot and must resolve at its pins too.
104
133
  const sources = [
105
- ...sourceReferences(snapshot.document),
134
+ ...sourceReferences(snapshot.document, { repositoryId }),
106
135
  ...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
107
136
  sourceAnchors(selection.source).map((source) => ({
108
137
  ...selection,
@@ -116,7 +145,7 @@ export async function exportShare(input: {
116
145
 
117
146
  validateShareSources(snapshot);
118
147
 
119
- if (Boolean(pins) !== Boolean(input.repository))
148
+ if ((pins || referenceCommits(stored).length) && !input.repository)
120
149
  throw new ReviewInputError("Shared source pins require a repository.", 409);
121
150
 
122
151
  for (const block of elements(snapshot.document)) {