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

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 (118) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-B5-HYi1D.js} +16 -9
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-CohHf6Ua.js} +15 -10
  3. package/dist/agent-cli-_YyMIMRY.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-Dtc-Kn_w.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-B7KU04f7.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-C03RKxKS.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-CB9C4uoc.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CJaEb7zC.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-DGDIqh3V.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-BmVJJm9I.js} +42 -14
  14. package/dist/{headless-host-CzvYpKOX.js → headless-host-DRtjf9WA.js} +1 -1
  15. package/dist/{headless-host-CS4Gv0c5.js → headless-host-XhL1X4vX.js} +523 -146
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-Cpl4ENbJ.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-ovmiUE_x.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-BadSkCkI.js} +20 -8
  21. package/dist/profile-DJvkBJNQ.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-Dd-YobM6.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-BXk87KCr.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DuOzsvaD.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-B9qCPiVB.js} +45 -12
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-C7el2Uoy.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-BsoRMC2w.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-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-CjcLyFV5.js} +5 -3
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-CKjw3-6h.js} +6 -5
  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-DvI-gys2.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-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/review-api/README.md +52 -13
  61. package/src/review-api/activity.ts +1 -4
  62. package/src/review-api/agent-cli.ts +9 -1
  63. package/src/review-api/anchor-quotes.ts +14 -7
  64. package/src/review-api/ask-history.ts +137 -51
  65. package/src/review-api/authoring-tools.ts +45 -30
  66. package/src/review-api/blocks/definition.ts +1 -1
  67. package/src/review-api/blocks/markdown.ts +2 -2
  68. package/src/review-api/board-order.ts +144 -0
  69. package/src/review-api/checkout-groups.ts +560 -0
  70. package/src/review-api/document-headings.ts +58 -27
  71. package/src/review-api/document-text.ts +31 -2
  72. package/src/review-api/document.ts +281 -34
  73. package/src/review-api/foreign-references.ts +328 -0
  74. package/src/review-api/github-provider.ts +51 -13
  75. package/src/review-api/http.ts +702 -123
  76. package/src/review-api/instructions.ts +0 -19
  77. package/src/review-api/link-preview.ts +113 -0
  78. package/src/review-api/local-data.ts +310 -46
  79. package/src/review-api/mcp.ts +3 -11
  80. package/src/review-api/onboarding.ts +1 -1
  81. package/src/review-api/profile.ts +37 -6
  82. package/src/review-api/public-tools.ts +24 -2
  83. package/src/review-api/pull-request.ts +82 -49
  84. package/src/review-api/request-origin.ts +10 -0
  85. package/src/review-api/review-progress.ts +61 -27
  86. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  87. package/src/review-api/store-migrations.ts +357 -0
  88. package/src/review-api/store-schema.ts +38 -29
  89. package/src/review-api/store.ts +2144 -480
  90. package/src/review-api/workspaces.ts +52 -13
  91. package/src/review-api/worktree-events.ts +3 -5
  92. package/src/review-api/worktree-source.ts +3 -7
  93. package/src/review-api/worktree-structural.integration.ts +72 -0
  94. package/src/review-stack.ts +15 -23
  95. package/src/runtime.ts +4 -30
  96. package/src/server/desktop-host.ts +6 -1
  97. package/src/server/desktop-server.ts +1 -31
  98. package/src/server/headless-host.ts +21 -14
  99. package/src/server/hono-http.ts +2 -1
  100. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  101. package/src/server/review-gateway-list.ts +3 -5
  102. package/src/server/review-gateway-memory.ts +1 -1
  103. package/src/server/review-lifecycle-telemetry.ts +2 -2
  104. package/src/server/review-server-core.ts +1 -5
  105. package/src/server/structural-comparisons.ts +47 -0
  106. package/src/server-discovery.ts +4 -2
  107. package/src/sharing/export.ts +36 -7
  108. package/src/sharing/host.ts +34 -14
  109. package/src/sharing/import.ts +156 -24
  110. package/src/sharing/repository.ts +128 -20
  111. package/src/startup-trace.ts +3 -3
  112. package/src/stored-document-migration.ts +110 -19
  113. package/src/ui-telemetry-events.ts +33 -11
  114. package/dist/agent-cli-DeCrUC2s.js +0 -2
  115. package/dist/profile-Cz7L781D.js +0 -2
  116. package/dist/runtime-CKiWhGrf.js +0 -52
  117. package/instructions/scratchpad.md +0 -23
  118. package/src/review-preferences.ts +0 -88
@@ -5,7 +5,12 @@ import path from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
 
7
7
  import { git, gitCommonDir } from "@dev.fast/local-vcs";
8
- import { errorMessage, processIsAlive } from "@dev.fast/trace-core";
8
+ import {
9
+ errorMessage,
10
+ pidReused,
11
+ processIsAlive,
12
+ processStartIdentity,
13
+ } from "@dev.fast/trace-core";
9
14
  import { reviewManagedCheckoutRoot } from "@review/review-checkout-paths.js";
10
15
  import {
11
16
  ensureReviewPinnedCheckout,
@@ -48,9 +53,6 @@ interface Environment extends WorkspaceStatus {
48
53
  role: "base" | "head";
49
54
  }
50
55
 
51
- const OWNED_ELSEWHERE =
52
- "Another Whiteboard server is managing this review's language workspaces. Retry once it stops.";
53
-
54
56
  /** Local lifecycle only: source and authored history never depend on preparation.
55
57
  * Status/queue/process lifecycle follows #334, retaining the legacy prepare config
56
58
  * and per-review managed checkout layout instead of a new settings system.
@@ -75,11 +77,21 @@ export class ReviewWorkspaces {
75
77
  this.db.exec(
76
78
  "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS pinned_environments(id TEXT PRIMARY KEY, value TEXT NOT NULL)",
77
79
  );
80
+
78
81
  // Desktops sharing a profile each own the reviews they prepare; the
79
82
  // lease stops a second process from preparing or collecting them. The
80
83
  // profile-wide lock it replaces only ever shipped in preview builds.
84
+ // Older leases have no start identity and may name a reused pid.
85
+ if (
86
+ !this.db
87
+ .prepare(
88
+ "SELECT 1 FROM pragma_table_info('workspace_leases') WHERE name='started'",
89
+ )
90
+ .get()
91
+ )
92
+ this.db.exec("DROP TABLE IF EXISTS workspace_leases");
81
93
  this.db.exec(
82
- "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)",
94
+ "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)",
83
95
  );
84
96
  this.db.exec("BEGIN IMMEDIATE");
85
97
 
@@ -284,7 +296,8 @@ export class ReviewWorkspaces {
284
296
  }
285
297
 
286
298
  async remove(reviewId: string) {
287
- if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
299
+ if (!this.claim(reviewId))
300
+ throw new ReviewInputError(this.ownedElsewhere(reviewId), 409);
288
301
  await Promise.all(this.requests.values());
289
302
  this.collect(undefined, reviewId);
290
303
  await this.cleanup;
@@ -297,9 +310,19 @@ export class ReviewWorkspaces {
297
310
  }
298
311
 
299
312
  private lease(reviewId: string) {
300
- return this.db
301
- .prepare("SELECT owner,pid FROM workspace_leases WHERE review_id=?")
313
+ const row = this.db
314
+ .prepare(
315
+ "SELECT owner,pid,started FROM workspace_leases WHERE review_id=?",
316
+ )
302
317
  .get(reviewId);
318
+
319
+ return (
320
+ row && {
321
+ owner: String(row.owner),
322
+ pid: Number(row.pid),
323
+ started: String(row.started ?? ""),
324
+ }
325
+ );
303
326
  }
304
327
 
305
328
  private leasedElsewhere(reviewId: string): boolean {
@@ -308,10 +331,20 @@ export class ReviewWorkspaces {
308
331
  return Boolean(
309
332
  lease &&
310
333
  lease.owner !== this.ownerId &&
311
- processIsAlive(Number(lease.pid)),
334
+ processIsAlive(lease.pid) &&
335
+ !pidReused(lease.pid, lease.started),
312
336
  );
313
337
  }
314
338
 
339
+ /** The holder may release its lease after `claim` fails. */
340
+ private ownedElsewhere(reviewId: string) {
341
+ const pid = this.lease(reviewId)?.pid;
342
+
343
+ return pid
344
+ ? `Whiteboard process ${pid} is managing this review's language workspaces. Quit that Whiteboard Desktop or run \`whiteboard server stop\`, then retry.`
345
+ : "Another Whiteboard process was managing this review's language workspaces. Retry.";
346
+ }
347
+
315
348
  /** Takes the review's lease unless another live Desktop holds it. */
316
349
  private claim(reviewId: string): boolean {
317
350
  // Status reads claim on every poll; holding the lease needs no write lock.
@@ -323,8 +356,13 @@ export class ReviewWorkspaces {
323
356
 
324
357
  if (free)
325
358
  this.db
326
- .prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?)")
327
- .run(reviewId, this.ownerId, process.pid);
359
+ .prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?,?)")
360
+ .run(
361
+ reviewId,
362
+ this.ownerId,
363
+ process.pid,
364
+ processStartIdentity(process.pid),
365
+ );
328
366
  this.db.exec("COMMIT");
329
367
 
330
368
  return free;
@@ -437,7 +475,7 @@ export class ReviewWorkspaces {
437
475
  rootPath: null,
438
476
  generation: "",
439
477
  state: "pending",
440
- log: OWNED_ELSEWHERE,
478
+ log: this.ownedElsewhere(reviewId),
441
479
  },
442
480
  );
443
481
  }
@@ -607,7 +645,8 @@ export class ReviewWorkspaces {
607
645
 
608
646
  if (this.jobs.has(id)) return this.status(environment);
609
647
 
610
- if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
648
+ if (!this.claim(reviewId))
649
+ throw new ReviewInputError(this.ownedElsewhere(reviewId), 409);
611
650
 
612
651
  if (environment.state === "cleanup-failed") {
613
652
  this.collect();
@@ -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
 
@@ -8,7 +8,11 @@ import path from "node:path";
8
8
  import { setTimeout as delay } from "node:timers/promises";
9
9
 
10
10
  import { isObjectValue } from "@dev.fast/json";
11
- import { withFileLock, writePrivateJsonAtomic } from "@dev.fast/trace-core";
11
+ import {
12
+ processStartIdentity,
13
+ withFileLock,
14
+ writePrivateJsonAtomic,
15
+ } from "@dev.fast/trace-core";
12
16
  import type { AskAgentLauncher } from "@review/ask/agents.js";
13
17
  import { cliAskTools } from "@review/ask/threads.js";
14
18
  import { desktopUsesHome } from "@review/desktop-discovery.js";
@@ -19,12 +23,12 @@ import {
19
23
  type ReviewServerDiscovery,
20
24
  desktopOwnsHome,
21
25
  headlessServerLockPath,
22
- readReviewServerDiscovery,
23
26
  reviewServerDiscoveryPath,
24
27
  } from "@review/server-discovery.js";
25
28
 
26
29
  import {
27
30
  headlessServerOwner,
31
+ recordedBackgroundServer,
28
32
  stopBackgroundServer,
29
33
  } from "./background-server.js";
30
34
  import { migrateDiffrConfig } from "./diffr-config.js";
@@ -96,9 +100,10 @@ export function withHeadlessServerLock<T>(
96
100
  export async function takeOverServerHome<T>(
97
101
  home: string,
98
102
  start: () => Promise<T>,
103
+ { timeoutMs = 30_000 }: { timeoutMs?: number } = {},
99
104
  ): Promise<T> {
100
105
  const stateDir = await realpath(home);
101
- const deadline = Date.now() + 30_000;
106
+ const deadline = Date.now() + timeoutMs;
102
107
 
103
108
  while (true) {
104
109
  const outcome = await withHeadlessServerLock(stateDir, start);
@@ -106,18 +111,22 @@ export async function takeOverServerHome<T>(
106
111
  if (outcome.acquired) return outcome.result;
107
112
 
108
113
  const owner = await headlessServerOwner(stateDir);
109
-
110
- const discovery = await readReviewServerDiscovery(stateDir).catch(
111
- () => null,
112
- );
113
-
114
- if (owner !== undefined && discovery?.serverPid === owner)
115
- await stopBackgroundServer(discovery, DESKTOP_TAKEOVER_SIGNAL);
116
- else if (Date.now() > deadline)
114
+ // A server that answers as its recorded pid; a reused pid would not.
115
+ const server = await recordedBackgroundServer(stateDir);
116
+
117
+ if (owner !== undefined && server?.discovery.serverPid === owner)
118
+ await stopBackgroundServer(server.discovery, DESKTOP_TAKEOVER_SIGNAL);
119
+ else if (Date.now() <= deadline) await delay(100);
120
+ // Without a start time (Windows), nothing proves the owner is the server.
121
+ else if (owner !== undefined && processStartIdentity(owner) === null)
122
+ await rm(headlessServerLockPath(stateDir), {
123
+ recursive: true,
124
+ force: true,
125
+ });
126
+ else
117
127
  throw new Error(
118
128
  `Process ${owner} holds the Whiteboard server's lock in ${stateDir} without serving it.`,
119
129
  );
120
- else await delay(100);
121
130
  }
122
131
  }
123
132
 
@@ -212,8 +221,6 @@ async function serve(input: HeadlessServerInput) {
212
221
  token: discovery.token,
213
222
  instanceId: discovery.instanceId,
214
223
  softwareMapEnabled: input.softwareMapEnabled,
215
- // The scratchpad is the laptop's alone, even with a Desktop attached.
216
- scratchpad: () => false,
217
224
  status: () => ({ key: "headless", home: input.stateDir }),
218
225
  ask: {
219
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);