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

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-B5-HYi1D.js → account-alias-CprZojXd.js} +8 -13
  2. package/dist/agent-cli-C6SPSII0.js +2 -0
  3. package/dist/{agent-cli-CohHf6Ua.js → agent-cli-CThN7Q59.js} +10 -15
  4. package/dist/{agent-client-Dtc-Kn_w.js → agent-client-BOOi7EUR.js} +3 -13
  5. package/dist/{authoring-tools-B7KU04f7.js → authoring-tools-BNwuMnz6.js} +27 -34
  6. package/dist/{background-server-C03RKxKS.js → background-server-CkvxA-6O.js} +5 -5
  7. package/dist/{background-server-CB9C4uoc.js → background-server-DLSDogkI.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-CJaEb7zC.js → cli-R4yfa5DT.js} +1 -1
  10. package/dist/{cli-runner-DGDIqh3V.js → cli-runner-Cuc9FSzu.js} +26 -26
  11. package/dist/cli.js +3 -4
  12. package/dist/{client-lKVF_Elo.js → client-BN_FDxFr.js} +47 -253
  13. package/dist/{fuzzy-match-BmVJJm9I.js → fuzzy-match-BO9bK37N.js} +4 -6
  14. package/dist/{headless-host-DRtjf9WA.js → headless-host-CVBOQyRS.js} +1 -1
  15. package/dist/{headless-host-XhL1X4vX.js → headless-host-Cog6SpS9.js} +140 -513
  16. package/dist/{local-data-Cpl4ENbJ.js → local-data-D_9tvdsp.js} +323 -2419
  17. package/dist/{mcp-ovmiUE_x.js → mcp-D1hziANa.js} +9 -5
  18. package/dist/{package-paths-BWxO-UEL.js → package-paths-C--__jEv.js} +1 -1
  19. package/dist/profile-CYPmvBoA.js +2 -0
  20. package/dist/{profile-BadSkCkI.js → profile-DokFw782.js} +8 -20
  21. package/dist/{remote-attach-Dd-YobM6.js → remote-attach-CEJ-kvDY.js} +4 -4
  22. package/dist/{remote-extensions-CNXwqK0D.js → remote-extensions-Blcjr9Ze.js} +3 -3
  23. package/dist/{remote-extensions-VP-kfq0C.js → remote-extensions-GN8Gf_qG.js} +1 -1
  24. package/dist/{remote-language-server-BXk87KCr.js → remote-language-server-BTKBQ7x4.js} +5 -5
  25. package/dist/{remote-uninstall-DuOzsvaD.js → remote-uninstall-DW7Gdryh.js} +6 -6
  26. package/dist/{request-origin-B9qCPiVB.js → request-origin-Zcn_CjX4.js} +12 -45
  27. package/dist/{review-app-launcher-C7el2Uoy.js → review-app-launcher-CkwLclzR.js} +2 -2
  28. package/dist/{review-home-paths-D8AoCdd2.js → review-home-paths-j2BRAaNv.js} +17 -19
  29. package/dist/{review-telemetry-BsoRMC2w.js → review-telemetry-BLNwYnLV.js} +5 -5
  30. package/dist/runtime-CKiWhGrf.js +52 -0
  31. package/dist/runtime.d.ts +1 -1
  32. package/dist/runtime.js +1 -1
  33. package/dist/{s3-CsG2EWED.js → s3-DtJFWC0G.js} +1 -1
  34. package/dist/{s3-config-CjcLyFV5.js → s3-config-BTO12tCC.js} +2 -4
  35. package/dist/{s3-config-zz4GWh2T.js → s3-config-CSy3fx8H.js} +1 -1
  36. package/dist/server/desktop-host.js +76 -29
  37. package/dist/{server-discovery-CKjw3-6h.js → server-discovery-Bey8IqXH.js} +3 -3
  38. package/dist/sharing/index.d.ts +70 -484
  39. package/dist/sharing/index.js +1 -1
  40. package/dist/{src-WPaQn75m.js → src-BMPuxWv4.js} +73 -208
  41. package/dist/{src-BiWNbcF_.js → src-DsM3jO8G.js} +12 -89
  42. package/dist/{stored-document-migration-dFbewRTG.js → stored-document-migration-CChxtFAy.js} +269 -270
  43. package/dist/{thread-state-Do5LX10I.js → thread-state-5pYr6_zM.js} +2 -7
  44. package/dist/{tool-failure-DvI-gys2.js → tool-failure-C9MpbXJJ.js} +3 -10
  45. package/instructions/authoring.md +2 -3
  46. package/instructions/scratchpad.md +23 -0
  47. package/package.json +4 -4
  48. package/src/ask/agents.ts +1 -13
  49. package/src/ask/checkout-files.ts +5 -2
  50. package/src/ask/thread-state.ts +0 -4
  51. package/src/ask/thread.ts +0 -16
  52. package/src/ask/threads.ts +3 -24
  53. package/src/cli-install.ts +2 -13
  54. package/src/cli-runner.ts +1 -4
  55. package/src/cli.ts +1 -4
  56. package/src/diff-selection-migration.ts +34 -48
  57. package/src/lens-selection.ts +2 -38
  58. package/src/review-api/README.md +13 -52
  59. package/src/review-api/activity.ts +4 -1
  60. package/src/review-api/agent-cli.ts +1 -9
  61. package/src/review-api/anchor-quotes.ts +7 -14
  62. package/src/review-api/ask-history.ts +51 -137
  63. package/src/review-api/authoring-tools.ts +30 -45
  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/document-headings.ts +27 -58
  67. package/src/review-api/document-text.ts +2 -31
  68. package/src/review-api/document.ts +34 -281
  69. package/src/review-api/github-provider.ts +13 -51
  70. package/src/review-api/http.ts +123 -702
  71. package/src/review-api/instructions.ts +19 -0
  72. package/src/review-api/local-data.ts +46 -310
  73. package/src/review-api/mcp.ts +11 -3
  74. package/src/review-api/onboarding.ts +1 -1
  75. package/src/review-api/profile.ts +6 -37
  76. package/src/review-api/public-tools.ts +2 -24
  77. package/src/review-api/pull-request.ts +49 -82
  78. package/src/review-api/request-origin.ts +0 -10
  79. package/src/review-api/review-progress.ts +27 -61
  80. package/src/review-api/store-schema.ts +29 -38
  81. package/src/review-api/store.ts +478 -2142
  82. package/src/review-api/worktree-events.ts +5 -3
  83. package/src/review-api/worktree-source.ts +7 -3
  84. package/src/review-api/worktree-structural.integration.ts +0 -72
  85. package/src/review-preferences.ts +88 -0
  86. package/src/review-stack.ts +23 -15
  87. package/src/runtime.ts +30 -4
  88. package/src/server/desktop-host.ts +1 -6
  89. package/src/server/desktop-server.ts +31 -1
  90. package/src/server/headless-host.ts +2 -0
  91. package/src/server/hono-http.ts +1 -2
  92. package/src/server/legacy-tutorial-cleanup.ts +3 -2
  93. package/src/server/review-gateway-list.ts +5 -3
  94. package/src/server/review-gateway-memory.ts +1 -1
  95. package/src/server/review-lifecycle-telemetry.ts +2 -2
  96. package/src/server/review-server-core.ts +5 -1
  97. package/src/server/structural-comparisons.ts +0 -47
  98. package/src/sharing/export.ts +7 -36
  99. package/src/sharing/host.ts +14 -34
  100. package/src/sharing/import.ts +24 -156
  101. package/src/sharing/repository.ts +20 -128
  102. package/src/startup-trace.ts +3 -3
  103. package/src/stored-document-migration.ts +19 -110
  104. package/src/ui-telemetry-events.ts +11 -33
  105. package/dist/agent-cli-_YyMIMRY.js +0 -2
  106. package/dist/lens-selection-CyJapcsl.js +0 -214
  107. package/dist/profile-DJvkBJNQ.js +0 -2
  108. package/dist/runtime-Bj2tD33R.js +0 -27
  109. package/src/heading-anchors.ts +0 -42
  110. package/src/review-api/board-order.ts +0 -144
  111. package/src/review-api/checkout-groups.ts +0 -560
  112. package/src/review-api/foreign-references.ts +0 -328
  113. package/src/review-api/link-preview.ts +0 -113
  114. package/src/review-api/scratchpad-plan-upgrade.ts +0 -250
  115. package/src/review-api/store-migrations.ts +0 -357
@@ -1,23 +1,11 @@
1
- import {
2
- type JsonObject,
3
- type JsonValue,
4
- isJsonObject,
5
- isStringValue,
6
- } from "@dev.fast/json";
7
- import { defaultBranch, resolveRepoContextSync } from "@dev.fast/local-vcs";
1
+ import { type JsonObject, isJsonObject } from "@dev.fast/json";
8
2
  import {
9
3
  REVIEW_CLIENT_HEADER,
10
4
  REVIEW_CLIENT_REMOTE,
11
- type ReviewApiSummary,
12
- type ReviewBoardPlace,
13
- type ReviewProjectThreads,
14
5
  type ReviewRemoteLanguageEnvironment,
15
6
  type ReviewRemoteNavigatorAnswer,
16
7
  type ReviewStreamLine,
17
8
  type ReviewStructuralDiffEvent,
18
- type ReviewThreadBoard,
19
- boardIconKind,
20
- boardTitle,
21
9
  } from "@dev.fast/review-protocol";
22
10
  import { errorMessage } from "@dev.fast/trace-core";
23
11
  import {
@@ -63,13 +51,12 @@ import { anchorQuotes } from "./anchor-quotes.js";
63
51
  import type { AskRecord } from "./ask-history.js";
64
52
  import { authoringTools } from "./authoring-tools.js";
65
53
  import { documentText } from "./document-text.js";
54
+ import { ReviewInputError } from "./document.js";
66
55
  import {
67
- ReviewInputError,
68
- type ReviewTarget,
69
- boardLinks,
70
- boardRepositoryId,
71
- } from "./document.js";
72
- import { instructionsQuerySchema, renderInstructions } from "./instructions.js";
56
+ instructionsQuerySchema,
57
+ renderInstructions,
58
+ scratchpadAvailable,
59
+ } from "./instructions.js";
73
60
  import type { LocalReviewData } from "./local-data.js";
74
61
  import { registerOnboardingRoutes } from "./onboarding.js";
75
62
  import {
@@ -78,8 +65,6 @@ import {
78
65
  readQuerySchemas,
79
66
  } from "./read-schemas.js";
80
67
  import {
81
- REVIEW_PERSON_HEADER,
82
- REVIEW_THREAD_HEADER,
83
68
  type ReviewRequestVia,
84
69
  reviewRequestOrigin,
85
70
  } from "./request-origin.js";
@@ -92,9 +77,8 @@ import {
92
77
  uncategorizedReport,
93
78
  } from "./review-progress.js";
94
79
  import {
95
- type Author,
96
- type PlacedBoard,
97
80
  type ReviewStore,
81
+ SCRATCHPAD_ID,
98
82
  type Snapshot,
99
83
  commandSchema,
100
84
  inspectSnapshot,
@@ -158,8 +142,13 @@ const askDecisionSchema = z.strictObject({
158
142
  export interface AuthoringCapabilities {
159
143
  desktopAvailable: boolean;
160
144
  softwareMapEnabled: boolean;
145
+ /** Off, the host neither makes nor lists the scratchpad, and refuses its id. */
146
+ scratchpadEnabled: boolean;
161
147
  }
162
148
 
149
+ const SCRATCHPAD_DISABLED =
150
+ "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
151
+
163
152
  /**
164
153
  * What the host reports about reviews, for telemetry. `onReviewCreated` fires
165
154
  * again for a replayed create command; consumers dedupe by review id.
@@ -167,6 +156,7 @@ export interface AuthoringCapabilities {
167
156
  export interface ReviewApiHooks {
168
157
  onReviewCreated?: (event: {
169
158
  reviewId: string;
159
+ kind: "review" | "scratchpad";
170
160
  blocks: number;
171
161
  via: ReviewRequestVia;
172
162
  agentKind?: ReviewSessionAgent;
@@ -174,14 +164,6 @@ export interface ReviewApiHooks {
174
164
  sharing?: SharingHostEvents;
175
165
  }
176
166
 
177
- /** A person's save carries the whole board, which edits one at a time can
178
- * grow past the size of a single command. */
179
- const PERSON_EDIT_MAX_BYTES = 16 * 1024 * 1024;
180
-
181
- /** A request the gateway forwarded from another machine. */
182
- const remoteCaller = (context: Context) =>
183
- context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
184
-
185
167
  /** Both hosts mount this behind their token authentication. */
186
168
  export function createReviewApi(
187
169
  store: ReviewStore,
@@ -192,11 +174,14 @@ export function createReviewApi(
192
174
  }) => Promise<{ softwareMapEnabled: boolean }>,
193
175
  shared?: SharedReviewStore,
194
176
  capabilities: () =>
195
- | AuthoringCapabilities
196
- | Promise<AuthoringCapabilities> = () => ({
177
+ | Omit<AuthoringCapabilities, "scratchpadEnabled">
178
+ | Promise<Omit<AuthoringCapabilities, "scratchpadEnabled">> = () => ({
197
179
  desktopAvailable: Boolean(open),
198
180
  softwareMapEnabled: false,
199
181
  }),
182
+ // Synchronous because the catalog is read inside watch callbacks. The host
183
+ // keeps it current from its preferences file.
184
+ scratchpadEnabled: () => boolean = () => false,
200
185
  // Read per request: capture can change from outside this server.
201
186
  traceEnabled: () => Promise<boolean> = async () => false,
202
187
  /** Which server this is, for whiteboard_status. */
@@ -267,7 +252,23 @@ export function createReviewApi(
267
252
  operation: z.object({ reviewId: z.string().optional() }),
268
253
  });
269
254
 
255
+ // The host that can show the scratchpad keeps it: Desktop, while the
256
+ // preference is on. Headless servers never make one, and a pad made earlier
257
+ // stays in the store while it is off.
258
+ const ensureScratchpad = async (id?: string) => {
259
+ if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID))
260
+ await store.ensureScratchpad();
261
+ };
262
+
263
+ const refuseDisabledScratchpad = (id?: string) => {
264
+ if (id === SCRATCHPAD_ID && !scratchpadEnabled())
265
+ throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
266
+ };
267
+
270
268
  const sharedGuard: MiddlewareHandler = async (context, next) => {
269
+ refuseDisabledScratchpad(context.req.param("id"));
270
+ await ensureScratchpad(context.req.param("id"));
271
+
271
272
  const id = context.req.param("id");
272
273
 
273
274
  if (!id || !isShared(id)) return next();
@@ -299,68 +300,22 @@ export function createReviewApi(
299
300
  return snapshot;
300
301
  };
301
302
 
302
- /** Adds a PR board's state, as this host last read it from GitHub. */
303
- const withPullRequestState = <
304
- Board extends {
305
- pullRequestUrl?: string;
306
- origin?: { pullRequestUrl?: string };
307
- },
308
- >(
309
- board: Board,
310
- ) => {
311
- const state = data?.pullRequestState(
312
- board.origin?.pullRequestUrl ?? board.pullRequestUrl,
313
- );
303
+ const catalog = () => {
304
+ const local = store.list();
314
305
 
315
- return state ? { ...board, pullRequestState: state } : board;
306
+ return [
307
+ ...(scratchpadEnabled()
308
+ ? local
309
+ : local.filter((summary) => summary.kind !== "scratchpad")),
310
+ ...(shared?.list() ?? []),
311
+ ];
316
312
  };
317
313
 
318
- const catalog = () => [
319
- ...store.list().map(withPullRequestState),
320
- ...(shared?.list() ?? []),
321
- ];
322
-
323
- /** Where a board sits among its project's, and its PR's facts, for the
324
- * board's header. */
325
- const placeOf = (id: string): ReviewBoardPlace => {
326
- if (isShared(id) || !store.has(id)) return { siblings: [] };
327
-
328
- const { board, parent, siblings, children } = store.place(id);
329
- const url = board.origin?.pullRequestUrl;
330
- const state = data?.pullRequestState(url);
331
- const baseBranch = url && data?.pullRequestBaseBranch(url);
332
- const headBranch = url && data?.pullRequestHeadBranch(url);
333
-
334
- const place = ({ reviewId, title, target }: PlacedBoard) => ({
335
- reviewId,
336
- title,
337
- icon: boardIconKind(target),
338
- });
339
-
340
- const listed = (child: PlacedBoard) => {
341
- const childState = data?.pullRequestState(child.origin?.pullRequestUrl);
314
+ app.get("/", async (context) => {
315
+ await ensureScratchpad();
342
316
 
343
- return {
344
- ...place(child),
345
- ...(child.origin?.pullRequestNumber && {
346
- pullRequestNumber: child.origin.pullRequestNumber,
347
- }),
348
- ...(childState && { pullRequestState: childState }),
349
- };
350
- };
351
-
352
- return {
353
- ...(state && { pullRequestState: state }),
354
- ...(baseBranch && { baseBranch }),
355
- ...(headBranch && { headBranch }),
356
- board: place(board),
357
- ...(parent && { parent: place(parent) }),
358
- siblings: siblings.map(listed),
359
- ...(children?.length && { children: children.map(listed) }),
360
- };
361
- };
362
-
363
- app.get("/", (context) => context.json(catalog()));
317
+ return context.json(catalog());
318
+ });
364
319
 
365
320
  app.get("/feedback-eligibility", (context) =>
366
321
  context.json({ eligible: store.feedbackEligible() }),
@@ -369,13 +324,20 @@ export function createReviewApi(
369
324
  // Server-owned state only: asking the Desktop canvas would let a stalled
370
325
  // renderer block tool listing and the first instructions call.
371
326
  const instructionContext = async () => ({
327
+ desktopAvailable: Boolean(open),
328
+ scratchpadEnabled: scratchpadEnabled(),
372
329
  traceEnabled: await traceEnabled(),
373
330
  });
374
331
 
375
332
  app.get("/authoring", async (context) => {
376
333
  const instructions = await instructionContext();
377
334
 
378
- return context.json(authoringTools(instructions.traceEnabled));
335
+ return context.json(
336
+ authoringTools(
337
+ scratchpadAvailable(instructions),
338
+ instructions.traceEnabled,
339
+ ),
340
+ );
379
341
  });
380
342
  app.get("/instructions", async (context) => {
381
343
  const { topic } = instructionsQuerySchema.parse(context.req.query());
@@ -506,7 +468,10 @@ export function createReviewApi(
506
468
  );
507
469
 
508
470
  app.get("/capabilities", async (context) =>
509
- context.json(await capabilities()),
471
+ context.json({
472
+ ...(await capabilities()),
473
+ scratchpadEnabled: scratchpadEnabled(),
474
+ }),
510
475
  );
511
476
 
512
477
  app.get("/:id/activity", (context) => {
@@ -538,27 +503,6 @@ export function createReviewApi(
538
503
  );
539
504
  },
540
505
  );
541
- // The person's canvas, never an agent tool: a save of the whole board,
542
- // the block in focus, and discarding a board left untouched.
543
- app.post("/:id/person-edit", async (context) =>
544
- context.json(
545
- await store.personEdit(
546
- context.req.param("id"),
547
- await readBoundedRequestJson(context.req.raw, PERSON_EDIT_MAX_BYTES),
548
- ),
549
- ),
550
- );
551
- app.post("/:id/focus", async (context) =>
552
- context.json(
553
- store.focus(
554
- context.req.param("id"),
555
- await readBoundedRequestJson(context.req.raw),
556
- ),
557
- ),
558
- );
559
- app.post("/:id/discard", async (context) =>
560
- context.json(await store.discard(context.req.param("id"))),
561
- );
562
506
  app.get("/watch", (context) => {
563
507
  let input: unknown;
564
508
 
@@ -586,36 +530,6 @@ export function createReviewApi(
586
530
  let dirtyList = lists;
587
531
  const dirtyReviews = new Set(reviewIds);
588
532
  let sentList: string | undefined;
589
- // Bumped when a board a review links to changes, so its links reload.
590
- const linksRevision = new Map<string, number>();
591
-
592
- // The PR states its links show: only a change to one of them sends the
593
- // review again. GitHub answers for every PR on launch, a few at a time.
594
- const linksKey = (id: string) => {
595
- if (!data || isShared(id) || !store.has(id)) return "";
596
- const { outgoing, backlinks } = store.links(id);
597
-
598
- return JSON.stringify(
599
- [...outgoing, ...backlinks].map(
600
- (link) =>
601
- link.pullRequestUrl &&
602
- data.pullRequestState(link.pullRequestUrl, { seen: false }),
603
- ),
604
- );
605
- };
606
-
607
- const linksKeys = new Map(
608
- [...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)] as const),
609
- );
610
-
611
- // Bumped when a review's parent or siblings change, so its header
612
- // reloads them.
613
- const placeRevision = new Map<string, number>();
614
- const placeKey = (id: string) => JSON.stringify(placeOf(id));
615
-
616
- const placeKeys = new Map(
617
- [...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)] as const),
618
- );
619
533
 
620
534
  const readReviewLine = (reviewId: string): ReviewStreamLine<Snapshot> => {
621
535
  try {
@@ -626,8 +540,6 @@ export function createReviewApi(
626
540
  ...readReview(reviewId),
627
541
  activity: store.activity.read(reviewId),
628
542
  coverageRevision: data?.coverageRevision ?? 0,
629
- linksRevision: linksRevision.get(reviewId) ?? 0,
630
- placeRevision: placeRevision.get(reviewId) ?? 0,
631
543
  },
632
544
  };
633
545
  } catch (error) {
@@ -672,22 +584,6 @@ export function createReviewApi(
672
584
  return true;
673
585
  };
674
586
 
675
- /** Bumps the place of each board whose place changed. */
676
- const refreshPlaces = () => {
677
- let marked = false;
678
-
679
- for (const [id, key] of placeKeys) {
680
- const next = placeKey(id);
681
-
682
- if (next === key) continue;
683
- placeKeys.set(id, next);
684
- placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
685
- marked = markReview(id) || marked;
686
- }
687
-
688
- return marked;
689
- };
690
-
691
587
  const markLists = () => {
692
588
  dirtyList ||= lists;
693
589
 
@@ -702,14 +598,7 @@ export function createReviewApi(
702
598
  notify();
703
599
  }) ?? (() => {}),
704
600
  store.subscribe((result) => {
705
- let marked = markReview(result.reviewId);
706
-
707
- for (const from of store.linkingBoards(result.reviewId)) {
708
- linksRevision.set(from, (linksRevision.get(from) ?? 0) + 1);
709
- marked = markReview(from) || marked;
710
- }
711
-
712
- if (marked) notify();
601
+ if (markReview(result.reviewId)) notify();
713
602
  }),
714
603
  store.activity.subscribe((id) => {
715
604
  if (markReview(id)) notify();
@@ -717,31 +606,11 @@ export function createReviewApi(
717
606
  store.activity.subscribeWorking(() => {
718
607
  if (markLists()) notify();
719
608
  }),
720
- // A PR's state shows on its board in the list, on links to it, and
721
- // with its branches in its board's header.
722
- data?.subscribePullRequestStates(() => {
723
- let marked = markLists();
724
-
725
- marked = refreshPlaces() || marked;
726
-
727
- for (const reviewId of reviewIds) {
728
- const next = linksKey(reviewId);
729
-
730
- if (next === linksKeys.get(reviewId)) continue;
731
- linksKeys.set(reviewId, next);
732
- linksRevision.set(reviewId, (linksRevision.get(reviewId) ?? 0) + 1);
733
- marked = markReview(reviewId) || marked;
734
- }
735
-
736
- if (marked) notify();
737
- }) ?? (() => {}),
738
609
  shared?.subscribe(() => {
739
610
  if (markLists()) notify();
740
611
  }) ?? (() => {}),
741
612
  store.subscribeCatalog(() => {
742
- const marked = refreshPlaces();
743
-
744
- if (markLists() || marked) notify();
613
+ if (markLists()) notify();
745
614
  }),
746
615
  ];
747
616
 
@@ -815,15 +684,21 @@ export function createReviewApi(
815
684
  });
816
685
 
817
686
  // Traces are stored beside the review's own repository.
818
- const tracePins = async (id: string, version?: number) => {
819
- const snapshot = readReview(id, version);
687
+ const tracePins = (id: string, version?: number) => {
688
+ const { pins } = readReview(id, version);
820
689
 
821
- return snapshot.pins ?? (await data.documentPins(snapshot));
690
+ if (!pins)
691
+ throw new ReviewInputError(
692
+ "This document has no source pins of its own.",
693
+ 409,
694
+ );
695
+
696
+ return pins;
822
697
  };
823
698
 
824
699
  app.get("/:id/agent-traces", async (context) => {
825
700
  const query = traceQuery.parse(context.req.query());
826
- const pins = await tracePins(context.req.param("id"), query.version);
701
+ const pins = tracePins(context.req.param("id"), query.version);
827
702
 
828
703
  return context.json(
829
704
  await listPinnedTraces(
@@ -835,7 +710,7 @@ export function createReviewApi(
835
710
  });
836
711
  app.get("/:id/agent-traces/:sessionId", async (context) => {
837
712
  const query = traceQuery.parse(context.req.query());
838
- const pins = await tracePins(context.req.param("id"), query.version);
713
+ const pins = tracePins(context.req.param("id"), query.version);
839
714
 
840
715
  const result = await readStoredTrace(
841
716
  store.repositoryPath(pins.repositoryId),
@@ -914,27 +789,13 @@ export function createReviewApi(
914
789
 
915
790
  return context.json(await data!.register(input.path));
916
791
  });
917
- app.post("/resources", async (context) => {
918
- const body = await readBoundedRequestJson(
919
- context.req.raw,
920
- 8 * 1024 * 1024,
921
- );
922
-
923
- // Agents send a checkout path; swap it for a repository id.
924
- const named = z
925
- .looseObject({ repositoryPath: z.string().min(1) })
926
- .safeParse(body);
927
-
928
- if (!named.success) return context.json(await data!.upload(body));
929
- const { repositoryPath, ...upload } = named.data;
930
-
931
- return context.json(
932
- await data!.upload({
933
- ...upload,
934
- repositoryId: (await data!.register(repositoryPath)).id,
935
- }),
936
- );
937
- });
792
+ app.post("/resources", async (context) =>
793
+ context.json(
794
+ await data!.upload(
795
+ await readBoundedRequestJson(context.req.raw, 8 * 1024 * 1024),
796
+ ),
797
+ ),
798
+ );
938
799
  app.get("/:id/resources/:resourceId", async (context) => {
939
800
  const id = context.req.param("id");
940
801
  const snapshot = readReview(id);
@@ -1135,20 +996,6 @@ export function createReviewApi(
1135
996
 
1136
997
  return context.json(await data.changes(pins));
1137
998
  });
1138
- // A commit a quote is read at, in the board's repository.
1139
- app.get("/:id/commit/:commit", async (context) => {
1140
- const repositoryId = boardRepositoryId(
1141
- readReview(context.req.param("id")),
1142
- );
1143
-
1144
- if (!repositoryId)
1145
- throw new ReviewInputError("This board has no repository.", 404);
1146
-
1147
- return context.json(
1148
- await data.commitInfo(repositoryId, context.req.param("commit")),
1149
- );
1150
- });
1151
-
1152
999
  app.get("/:id/commits", async (context) => {
1153
1000
  const input = readQuerySchemas.commits.parse(context.req.query());
1154
1001
 
@@ -1238,7 +1085,7 @@ export function createReviewApi(
1238
1085
  );
1239
1086
 
1240
1087
  return [
1241
- `Whiteboard: ${boardTitle(snapshot.title)}`,
1088
+ `Whiteboard: ${snapshot.title}`,
1242
1089
  `Session ID: ${snapshot.reviewId}`,
1243
1090
  `Version: ${snapshot.version}`,
1244
1091
  ...(selection.apiSource?.commit
@@ -1246,6 +1093,7 @@ export function createReviewApi(
1246
1093
  : []),
1247
1094
  ...(selection.apiSource?.pins
1248
1095
  ? [
1096
+ `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
1249
1097
  ...(selection.apiSource.pins.base
1250
1098
  ? [`Selected base: ${selection.apiSource.pins.base}`]
1251
1099
  : []),
@@ -1254,6 +1102,7 @@ export function createReviewApi(
1254
1102
  : []),
1255
1103
  ...(snapshot.pins
1256
1104
  ? [
1105
+ `Repository ID: ${snapshot.pins.repositoryId}`,
1257
1106
  `Session base: ${snapshot.pins.base}`,
1258
1107
  `Session head: ${snapshot.pins.head}`,
1259
1108
  ]
@@ -1267,15 +1116,10 @@ export function createReviewApi(
1267
1116
  }
1268
1117
 
1269
1118
  if (ask && data) {
1270
- const projectOf = (reviewId: string) =>
1271
- isShared(reviewId) || !store.has(reviewId)
1272
- ? undefined
1273
- : store.projectOf(reviewId);
1274
-
1275
1119
  const readThread = (reviewId: string, threadId: string) => {
1276
1120
  const thread = ask.threads.get(threadId);
1277
1121
 
1278
- if (!thread)
1122
+ if (!thread || thread.reviewId !== reviewId)
1279
1123
  throw new ReviewInputError("This conversation has ended.", 404);
1280
1124
 
1281
1125
  return thread;
@@ -1354,7 +1198,7 @@ export function createReviewApi(
1354
1198
  const { reviewId } = snapshot;
1355
1199
 
1356
1200
  return [
1357
- `A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1201
+ `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1358
1202
  checkout.live
1359
1203
  ? "Your working directory is the repository the review describes."
1360
1204
  : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
@@ -1482,9 +1326,6 @@ export function createReviewApi(
1482
1326
 
1483
1327
  assertWritable(reviewId);
1484
1328
 
1485
- // Asked on screen: seen from the start.
1486
- store.askHistory.markViewed(id, createdAt);
1487
-
1488
1329
  const thread = ask.threads.open({
1489
1330
  id,
1490
1331
  reviewId,
@@ -1493,10 +1334,7 @@ export function createReviewApi(
1493
1334
  // replaces a session the agent could not reopen.
1494
1335
  ...historyHooks({
1495
1336
  id,
1496
- anchor:
1497
- target.kind === "text" && target.anchor
1498
- ? { board: reviewId, block: target.anchor.blockId }
1499
- : { board: reviewId },
1337
+ reviewId,
1500
1338
  agent: input.agent,
1501
1339
  version: snapshot.version,
1502
1340
  head: checkout.head,
@@ -1522,109 +1360,12 @@ export function createReviewApi(
1522
1360
  return context.json({ threadId: thread.id });
1523
1361
  });
1524
1362
 
1525
- /** Every project's threads for the sidebar: who runs them, where they
1526
- * started and live, and which boards they changed. */
1527
- const projectThreads = (reviewId: string): ReviewProjectThreads => {
1528
- const entries = store.askHistory.listAll();
1529
-
1530
- const ids = entries.map((entry) => entry.id);
1531
- const edited = store.boardsChangedByThreads(ids);
1532
- const viewed = store.askHistory.viewedAt(ids);
1533
-
1534
- const boards = new Map<string, ReviewThreadBoard>();
1535
-
1536
- const board = (id: string) => {
1537
- let found = boards.get(id);
1538
-
1539
- if (!found) {
1540
- found = store.has(id)
1541
- ? store.boardFace(id)
1542
- : { reviewId: id, icon: boardIconKind(undefined) };
1543
- boards.set(id, found);
1544
- }
1545
-
1546
- return found;
1547
- };
1548
-
1549
- const project = projectOf(reviewId);
1550
-
1551
- return {
1552
- board: reviewId,
1553
- ...(project && { project }),
1554
- threads: entries.map((entry) => {
1555
- const anchor = entry.anchor ?? { board: reviewId };
1556
- const status = ask.threads.get(entry.id)?.status;
1557
- const viewedAt = viewed.get(entry.id);
1558
-
1559
- // A turn finished since the reviewer last saw the thread.
1560
- const unread =
1561
- status !== "starting" &&
1562
- status !== "running" &&
1563
- status !== "waiting" &&
1564
- viewedAt !== undefined &&
1565
- entry.updatedAt > viewedAt;
1566
-
1567
- return {
1568
- id: entry.id,
1569
- ...(entry.projectId && { projectId: entry.projectId }),
1570
- ...(store.has(anchor.board) && {
1571
- place: store.boardPlace(anchor.board),
1572
- }),
1573
- agent: entry.agent,
1574
- agentName: askAgents[entry.agent].name,
1575
- opening: entry.question ?? entry.title,
1576
- pinned:
1577
- entry.selection.target.kind === "text" &&
1578
- entry.selection.target.anchor !== undefined,
1579
- ...(status && { status }),
1580
- ...(unread && { unread }),
1581
- updatedAt: entry.updatedAt,
1582
- anchor: {
1583
- board: board(anchor.board),
1584
- ...(anchor.block && { block: anchor.block }),
1585
- },
1586
- edited: edited.get(entry.id) ?? [],
1587
- };
1588
- }),
1589
- };
1590
- };
1591
-
1592
1363
  app.get("/:id/ask/threads", (context) => {
1593
1364
  const reviewId = context.req.param("id");
1594
1365
 
1595
1366
  readReview(reviewId);
1596
1367
 
1597
- if (context.req.query("watch") !== undefined) {
1598
- // Sent again only when it reads differently.
1599
- let sent = "";
1600
-
1601
- return watch(
1602
- () => {
1603
- const line = JSON.stringify(projectThreads(reviewId));
1604
-
1605
- if (line === sent) return [];
1606
- sent = line;
1607
-
1608
- return [line];
1609
- },
1610
- (notify) => {
1611
- const stops = [
1612
- store.askHistory.subscribe(notify),
1613
- ask.threads.subscribeStatus(notify),
1614
- // An edit, title or target of any board a thread may show; a
1615
- // deleted board may have been a thread's anchor.
1616
- store.subscribe(() => notify()),
1617
- ];
1618
-
1619
- return () => stops.forEach((stop) => stop());
1620
- },
1621
- );
1622
- }
1623
-
1624
- return context.json({
1625
- threads: store.askHistory.list(reviewId, projectOf(reviewId)),
1626
- board: reviewId,
1627
- });
1368
+ return context.json({ threads: store.askHistory.list(reviewId) });
1628
1369
  });
1629
1370
 
1630
1371
  // The threads of every open Ask in a review, over one connection. Before
@@ -1643,32 +1384,20 @@ export function createReviewApi(
1643
1384
  threads.map((threadId) => {
1644
1385
  const thread = ask.threads.get(threadId);
1645
1386
 
1646
- return [threadId, thread];
1387
+ return [
1388
+ threadId,
1389
+ thread?.reviewId === reviewId ? thread : undefined,
1390
+ ];
1647
1391
  }),
1648
1392
  ),
1649
1393
  );
1650
1394
  });
1651
1395
 
1652
- // The boards a thread changed: titles from any project, so only for a
1653
- // local caller.
1654
- app.get("/:id/ask/:threadId/boards", (context) => {
1655
- const threadId = context.req.param("threadId");
1656
- const record = store.askHistory.get(threadId);
1657
-
1658
- const anchor =
1659
- record?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
1660
-
1661
- if (remoteCaller(context) || !anchor)
1662
- throw new ReviewInputError("This conversation was not found.", 404);
1663
-
1664
- return context.json({ boards: store.boardsChangedBy(threadId) });
1665
- });
1666
-
1667
1396
  // A saved conversation, without starting its agent.
1668
1397
  app.get("/:id/ask/:threadId", (context) => {
1669
1398
  const record = store.askHistory.get(context.req.param("threadId"));
1670
1399
 
1671
- if (!record)
1400
+ if (record?.reviewId !== context.req.param("id"))
1672
1401
  throw new ReviewInputError("This conversation was not found.", 404);
1673
1402
  const target = record.selection.target;
1674
1403
 
@@ -1700,19 +1429,17 @@ export function createReviewApi(
1700
1429
  const threadId = context.req.param("threadId");
1701
1430
  const live = ask.threads.get(threadId);
1702
1431
 
1703
- if (live) return context.json({ threadId });
1432
+ if (live?.reviewId === reviewId) return context.json({ threadId });
1704
1433
  const record = store.askHistory.get(threadId);
1705
1434
 
1706
1435
  const { picks } = askOpenSchema.parse(
1707
1436
  await readBoundedRequestJson(context.req.raw, undefined, {}),
1708
1437
  );
1709
1438
 
1710
- if (!record)
1439
+ if (record?.reviewId !== reviewId)
1711
1440
  throw new ReviewInputError("This conversation was not found.", 404);
1712
1441
 
1713
- // The thread runs in the checkout of the board it started on.
1714
- const anchor = record.anchor.board;
1715
- const snapshot = readReview(anchor, record.version);
1442
+ const snapshot = readReview(reviewId, record.version);
1716
1443
  const checkout = await data.agentCheckout(snapshot);
1717
1444
  const target = record.selection.target;
1718
1445
 
@@ -1728,7 +1455,7 @@ export function createReviewApi(
1728
1455
  assertWritable(reviewId);
1729
1456
  ask.threads.open({
1730
1457
  id: record.id,
1731
- reviewId: anchor,
1458
+ reviewId,
1732
1459
  agent: record.agent,
1733
1460
  cwd: checkout.rootPath,
1734
1461
  head: checkout.head,
@@ -1920,16 +1647,6 @@ export function createReviewApi(
1920
1647
  },
1921
1648
  );
1922
1649
 
1923
- app.post("/:id/ask/:threadId/viewed", (context) => {
1924
- const threadId = context.req.param("threadId");
1925
-
1926
- if (!ask.threads.get(threadId) && !store.askHistory.get(threadId))
1927
- throw new ReviewInputError("This conversation was not found.", 404);
1928
- store.askHistory.markViewed(threadId);
1929
-
1930
- return context.json({ ok: true });
1931
- });
1932
-
1933
1650
  // Closing an Ask ends its agent; the conversation stays saved. A tab
1934
1651
  // switched away from or a reload leaves it running, to come back to.
1935
1652
  app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
@@ -1947,29 +1664,17 @@ export function createReviewApi(
1947
1664
  const reviewId = context.req.param("id");
1948
1665
  const threadId = context.req.param("threadId");
1949
1666
 
1950
- const record = store.askHistory.get(threadId);
1951
-
1952
- if (!record)
1667
+ if (store.askHistory.get(threadId)?.reviewId !== reviewId)
1953
1668
  throw new ReviewInputError("This conversation was not found.", 404);
1954
1669
 
1955
- if (ask.threads.get(threadId)) ask.threads.close(threadId);
1670
+ if (ask.threads.get(threadId)?.reviewId === reviewId)
1671
+ ask.threads.close(threadId);
1956
1672
  store.askHistory.delete(threadId);
1957
1673
 
1958
1674
  return context.json({ ok: true });
1959
1675
  });
1960
1676
  }
1961
1677
 
1962
- app.get("/:id/place", (context) => {
1963
- const id = context.req.param("id");
1964
-
1965
- if (!isShared(id)) store.assertExists(id);
1966
-
1967
- return context.json(placeOf(id));
1968
- });
1969
-
1970
- // The board's branch stack: its GitHub stack, else the reviews chained
1971
- // through the bases they name, bottom first. Apart from its place among
1972
- // boards.
1973
1678
  app.get("/:id/stack", async (context) => {
1974
1679
  const query = readQuerySchemas.get.parse(context.req.query());
1975
1680
  const id = context.req.param("id");
@@ -1977,94 +1682,14 @@ export function createReviewApi(
1977
1682
 
1978
1683
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
1979
1684
 
1980
- const repositoryPath = store
1981
- .repositories()
1982
- .find(
1983
- (repository) => repository.id === snapshot.pins!.repositoryId,
1984
- )?.path;
1985
-
1986
1685
  const layers = await resolveReviewStackLayers(snapshot, store.list(), {
1987
- repositoryPath,
1988
- });
1989
-
1990
- // The branch the stack sits on: the repository's default branch.
1991
- const trunk =
1992
- layers.length > 1 && repositoryPath
1993
- ? await defaultBranch(repositoryPath).catch(() => null)
1994
- : null;
1995
-
1996
- return context.json({
1997
- layers: layers.map((layer) => {
1998
- const state = data?.pullRequestState(layer.pullRequestUrl ?? undefined);
1999
-
2000
- return state ? { ...layer, pullRequestState: state } : layer;
2001
- }),
2002
- ...(trunk && { base: trunk.ref.replace(/^origin\//, "") }),
1686
+ repositoryPath: store
1687
+ .repositories()
1688
+ .find((repository) => repository.id === snapshot.pins!.repositoryId)
1689
+ ?.path,
2003
1690
  });
2004
- });
2005
-
2006
- // The board's project and its whiteboards, for the board's sidebar.
2007
- app.get("/:id/project", (context) => {
2008
- const id = context.req.param("id");
2009
-
2010
- if (isShared(id)) return context.json({ project: null, boards: [] });
2011
-
2012
- const project = store.summary(id)?.project ?? null;
2013
-
2014
- return context.json({
2015
- project,
2016
- boards: project
2017
- ? store
2018
- .list()
2019
- .filter(
2020
- (board) => board.project?.id === project.id && !board.trashedAt,
2021
- )
2022
- .map(withPullRequestState)
2023
- : [],
2024
- });
2025
- });
2026
- app.get("/:id/links", (context) => {
2027
- const id = context.req.param("id");
2028
-
2029
- // A shared board's links name boards on the sender's machine: shown,
2030
- // but not followed.
2031
- if (isShared(id))
2032
- return context.json({
2033
- outgoing: boardLinks(id, readReview(id).document).map(
2034
- ({ from, to }) => ({ from, to, missing: true, elsewhere: true }),
2035
- ),
2036
- backlinks: [],
2037
- });
2038
-
2039
- const { version } = readQuerySchemas.get
2040
- .pick({ version: true })
2041
- .parse({ version: context.req.query("version") });
2042
-
2043
- const { outgoing, backlinks } = store.links(id, version);
2044
-
2045
- return context.json({
2046
- outgoing: outgoing.map(withPullRequestState),
2047
- backlinks: backlinks.map(withPullRequestState),
2048
- });
2049
- });
2050
- // A link's hover card, read when it opens.
2051
- app.get("/:id/links/peek", (context) => {
2052
- const id = context.req.param("id");
2053
1691
 
2054
- if (isShared(id)) return context.json({});
2055
-
2056
- const { version } = readQuerySchemas.get
2057
- .pick({ version: true })
2058
- .parse({ version: context.req.query("version") });
2059
-
2060
- const board = context.req.query("board");
2061
- const block = context.req.query("block");
2062
-
2063
- if (!board) throw new ReviewInputError("Name the linked board.", 400);
2064
-
2065
- return context.json(
2066
- store.linkPeek(id, { board, ...(block && { block }) }, version),
2067
- );
1692
+ return context.json({ layers });
2068
1693
  });
2069
1694
 
2070
1695
  app.get("/:id/history", (context) => {
@@ -2078,44 +1703,9 @@ export function createReviewApi(
2078
1703
  version: snapshot.version,
2079
1704
  title: snapshot.title,
2080
1705
  createdAt: snapshot.createdAt,
2081
- author: "agent",
2082
1706
  },
2083
1707
  ]);
2084
1708
  });
2085
-
2086
- /** A board's relations as session_list reports them: only those it has. */
2087
- const listedRelations = ({
2088
- parentId,
2089
- children,
2090
- dependsOn,
2091
- }: ReturnType<typeof store.relations>) => {
2092
- const listed: Partial<ReturnType<typeof store.relations>> = {};
2093
-
2094
- if (parentId) listed.parentId = parentId;
2095
-
2096
- if (children.length) listed.children = children;
2097
-
2098
- if (dependsOn.length) listed.dependsOn = dependsOn;
2099
-
2100
- return listed;
2101
- };
2102
-
2103
- /** A board's relations, each board named by its title. */
2104
- const relatedBoards = (id: string) => {
2105
- const { parentId, children, dependsOn } = store.relations(id);
2106
-
2107
- const named = (board: string) => ({
2108
- id: board,
2109
- title: store.read(board).title,
2110
- });
2111
-
2112
- return {
2113
- ...(parentId && { parent: named(parentId) }),
2114
- children: children.map(named),
2115
- dependsOn: dependsOn.map(named),
2116
- };
2117
- };
2118
-
2119
1709
  app.get("/:id/inspect", (context) => {
2120
1710
  const query = inspectQuerySchema.parse(context.req.query());
2121
1711
  const id = context.req.param("id");
@@ -2123,19 +1713,12 @@ export function createReviewApi(
2123
1713
 
2124
1714
  return context.json(
2125
1715
  query.format === "text"
2126
- ? documentText(
2127
- snapshot,
2128
- query.targetId,
2129
- Boolean(query.full),
2130
- isShared(id) ? undefined : relatedBoards(id),
2131
- )
1716
+ ? documentText(snapshot, query.targetId, Boolean(query.full))
2132
1717
  : query.targetId !== undefined
2133
1718
  ? inspectSnapshot(snapshot, query.targetId)
2134
- : {
2135
- ...(query.full ? snapshot : inspectSnapshot(snapshot)),
2136
- // Its place among the project's boards, as session_list has it.
2137
- ...(!isShared(id) && listedRelations(store.relations(id))),
2138
- },
1719
+ : query.full
1720
+ ? snapshot
1721
+ : inspectSnapshot(snapshot),
2139
1722
  );
2140
1723
  });
2141
1724
  app.get("/:id", async (context) => {
@@ -2195,32 +1778,27 @@ export function createReviewApi(
2195
1778
  await readBoundedRequestJson(context.req.raw),
2196
1779
  );
2197
1780
 
2198
- // Without local data (a bare store), a path is recorded as it is.
2199
- const register = async (path: string) =>
2200
- data ? data.register(path) : store.registerRepository(path);
2201
-
2202
- const request = withBoardRepository(
2203
- await locateRepositories(
2204
- await pullRequestTargets(
2205
- body,
2206
- register,
2207
- (url, repositoryId) => {
2208
- if (!data)
2209
- throw new ReviewInputError("Repositories are unavailable.");
1781
+ const request = await locateRepositories(body, (path) => {
1782
+ if (!data) throw new ReviewInputError("Repositories are unavailable.");
2210
1783
 
2211
- return data.resolvePullRequest(url, { id: repositoryId });
2212
- },
2213
- (id) => store.read(id),
2214
- ),
2215
- register,
2216
- ),
2217
- (id) => (store.has(id) ? store.read(id) : undefined),
2218
- );
1784
+ return data.register(path);
1785
+ });
2219
1786
 
2220
1787
  const input = commandSchema.parse(request);
2221
1788
 
2222
1789
  const command = sharedCommandSchema.safeParse(input);
2223
1790
 
1791
+ if (command.success) {
1792
+ refuseDisabledScratchpad(command.data.operation.reviewId);
1793
+ await ensureScratchpad(command.data.operation.reviewId);
1794
+ }
1795
+
1796
+ if (
1797
+ input.operation.type === "create" &&
1798
+ input.operation.kind === "scratchpad"
1799
+ )
1800
+ refuseDisabledScratchpad(SCRATCHPAD_ID);
1801
+
2224
1802
  if (
2225
1803
  command.success &&
2226
1804
  command.data.operation.reviewId?.startsWith("shared-")
@@ -2269,23 +1847,7 @@ export function createReviewApi(
2269
1847
  throw new ReviewInputError("Shared reviews are read-only.", 409);
2270
1848
  }
2271
1849
 
2272
- // Who is writing, trusted only from local callers: a person from
2273
- // Desktop or the canvas, or an agent, perhaps a thread's. The store
2274
- // keeps each author's rules.
2275
- const threadId = remoteCaller(context)
2276
- ? undefined
2277
- : context.req.header(REVIEW_THREAD_HEADER)?.trim() || undefined;
2278
-
2279
- const author: Author =
2280
- !remoteCaller(context) &&
2281
- context.req.header(REVIEW_PERSON_HEADER) !== undefined
2282
- ? { kind: "person" }
2283
- : {
2284
- kind: "agent",
2285
- ...(threadId && { threadId }),
2286
- };
2287
-
2288
- const result = await store.execute(input, undefined, author);
1850
+ const result = await store.execute(input);
2289
1851
 
2290
1852
  if (
2291
1853
  result.deleted ||
@@ -2331,6 +1893,7 @@ export function createReviewApi(
2331
1893
  if (result.created !== false)
2332
1894
  hooks.onReviewCreated?.({
2333
1895
  reviewId: result.reviewId,
1896
+ kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
2334
1897
  blocks: store.read(result.reviewId).document.length,
2335
1898
  ...reviewRequestOrigin(context.req.raw.headers),
2336
1899
  });
@@ -2416,148 +1979,6 @@ async function locateRepositories(
2416
1979
  };
2417
1980
  }
2418
1981
 
2419
- /** Turns an agent's pullRequest target into what the store takes: a create
2420
- * from pullRequestUrl, or for set_target the PR's resolved commits. */
2421
- async function pullRequestTargets(
2422
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2423
- body: unknown,
2424
- register: (path: string) => Promise<{ id: string }>,
2425
- resolve: (
2426
- url: string,
2427
- repositoryId: string,
2428
- ) => Promise<{ target: ReviewTarget }>,
2429
- board: (id: string) => void,
2430
- ) {
2431
- const target = z.strictObject({
2432
- kind: z.literal("pullRequest"),
2433
- repositoryPath: z.string().min(1),
2434
- pullRequest: z.union([z.string().min(1), z.number().int().positive()]),
2435
- });
2436
-
2437
- // A set_target is checked whole before anything is registered or fetched.
2438
- const located = z
2439
- .looseObject({
2440
- operation: z.discriminatedUnion("type", [
2441
- z.looseObject({ type: z.literal("create"), target }),
2442
- z.strictObject({
2443
- type: z.literal("set_target"),
2444
- reviewId: z.string().min(1),
2445
- target,
2446
- }),
2447
- ]),
2448
- })
2449
- .safeParse(body);
2450
-
2451
- if (!located.success) return body;
2452
- const { target: pullRequest, ...operation } = located.data.operation;
2453
-
2454
- if (operation.type === "set_target") board(operation.reviewId);
2455
- const { id: repositoryId } = await register(pullRequest.repositoryPath);
2456
-
2457
- const url = pullRequestAt(
2458
- pullRequest.repositoryPath,
2459
- pullRequest.pullRequest,
2460
- );
2461
-
2462
- if (operation.type === "create")
2463
- return {
2464
- ...located.data,
2465
- operation: { ...operation, pullRequestUrl: url, repositoryId },
2466
- };
2467
-
2468
- return {
2469
- ...located.data,
2470
- operation: {
2471
- ...operation,
2472
- target: (await resolve(url, repositoryId)).target,
2473
- pullRequestUrl: url,
2474
- },
2475
- };
2476
- }
2477
-
2478
- /** A PR's URL: as given, or its number on the checkout's GitHub repository. */
2479
- function pullRequestAt(repositoryPath: string, pullRequest: string | number) {
2480
- if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest))
2481
- return pullRequest;
2482
-
2483
- const slug = resolveRepoContextSync(repositoryPath)?.githubSlug;
2484
-
2485
- if (!slug)
2486
- throw new ReviewInputError(
2487
- "This checkout has no GitHub remote to find the PR number in; pass the PR's URL.",
2488
- );
2489
-
2490
- return `https://github.com/${slug}/pull/${pullRequest}`;
2491
- }
2492
-
2493
- /** A pins object as an edit spells it: commits, with no other fields. A
2494
- * diagram may key its own data `pins`, and that is left alone. */
2495
- /** Pins as an agent writes them: the board's repository is the host's to
2496
- * add. */
2497
- const agentPinsSchema = z.strictObject({
2498
- head: z.string().min(1),
2499
- base: z.string().min(1).optional(),
2500
- });
2501
-
2502
- /** An object an agent meant as pins: one naming a head, base or repository.
2503
- * Content may have a field called `pins` that is nothing of the kind. */
2504
- const meantAsPins = (value: JsonValue): value is JsonObject =>
2505
- isJsonObject(value) &&
2506
- ["head", "base", "repositoryId"].some((key) => key in value);
2507
-
2508
- /** Sets every pins object in an edit to the board's own repository. */
2509
- function withBoardRepository(
2510
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2511
- body: unknown,
2512
- board: (id: string) => Snapshot | undefined,
2513
- ) {
2514
- const located = z
2515
- .looseObject({
2516
- operation: z.looseObject({
2517
- type: z.enum(["edit", "lens_edit"]),
2518
- reviewId: z.string(),
2519
- edit: z.json(),
2520
- }),
2521
- })
2522
- .safeParse(body);
2523
-
2524
- if (!located.success) return body;
2525
- const snapshot = board(located.data.operation.reviewId);
2526
-
2527
- const repositoryId = snapshot ? boardRepositoryId(snapshot) : undefined;
2528
-
2529
- const edit = JSON.parse(
2530
- JSON.stringify(located.data.operation.edit),
2531
- (key, value: JsonValue) => {
2532
- if (key !== "pins" || !meantAsPins(value)) return value;
2533
-
2534
- // Checked as the agent wrote them, so a mistake names head or base,
2535
- // never the repository the host adds.
2536
- const { repositoryId: _ignored, ...written } = value;
2537
- const pins = agentPinsSchema.safeParse(written);
2538
-
2539
- if (!pins.success)
2540
- throw new ReviewInputError(
2541
- `Write pins as {head, base?}, each a commit: ${pins.error.issues
2542
- .map((issue) => `pins.${issue.path.join(".")}: ${issue.message}`)
2543
- .join("; ")}.`,
2544
- );
2545
-
2546
- if (!repositoryId)
2547
- throw new ReviewInputError(
2548
- "This whiteboard has no repository for its pins to name commits of.",
2549
- );
2550
-
2551
- return { ...pins.data, repositoryId };
2552
- },
2553
- );
2554
-
2555
- return {
2556
- ...located.data,
2557
- operation: { ...located.data.operation, edit },
2558
- };
2559
- }
2560
-
2561
1982
  function watch(
2562
1983
  lines: () => string[],
2563
1984
  subscribe: (notify: () => void) => () => void,