@dev.fast/whiteboard 0.4.0 → 0.4.1-preview.20261026.138

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 (78) hide show
  1. package/dist/{account-alias-2yTAxoBx.js → account-alias-BPlPnzQM.js} +7 -7
  2. package/dist/{agent-cli-CHNDd4Ga.js → agent-cli-BbCf9uid.js} +11 -11
  3. package/dist/agent-cli-ipN_JmzG.js +2 -0
  4. package/dist/{agent-client-Dc0vHFJt.js → agent-client-DfpM8F5N.js} +3 -3
  5. package/dist/{authoring-tools-D8EkIDjm.js → authoring-tools-bm39DRoV.js} +23 -10
  6. package/dist/{background-server-DyWnPJyZ.js → background-server--0EeCCeV.js} +1 -1
  7. package/dist/{background-server-DHWHzpo5.js → background-server-B--5p72o.js} +6 -6
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-qZomwJmK.js → cli-DBTQIroM.js} +2 -2
  10. package/dist/{cli-runner-DFBaVGij.js → cli-runner-CRFCdb7G.js} +25 -25
  11. package/dist/cli.js +3 -3
  12. package/dist/{fuzzy-match-CfSxqzBF.js → fuzzy-match-l3mJZ1Ge.js} +5 -5
  13. package/dist/{headless-host-dwlqhqMY.js → headless-host-5ei3nHS8.js} +1 -1
  14. package/dist/{headless-host-DJhcab6r.js → headless-host-BkJ9OUQY.js} +131 -299
  15. package/dist/{client-p9WB8Sj0.js → import-Bwvf9Wos.js} +505 -390
  16. package/dist/{lens-selection-9byzEgvU.js → lens-selection-EuZdag4Q.js} +1 -1
  17. package/dist/{local-data-DHedfhxw.js → local-data-BCSeTyic.js} +98 -154
  18. package/dist/{mcp-Br-r_wwR.js → mcp-_Xg1gQuA.js} +17 -8
  19. package/dist/{package-paths-BeYHYjeR.js → package-paths-Bg731FHS.js} +2 -2
  20. package/dist/profile-C8zL4u3L.js +2 -0
  21. package/dist/{profile-D9wdlPgw.js → profile-xzrUFgbT.js} +4 -4
  22. package/dist/{remote-attach-DSGpTOJ4.js → remote-attach-DF25Epoa.js} +4 -4
  23. package/dist/{remote-extensions-WtJO7pkb.js → remote-extensions-BJamgsCe.js} +1 -1
  24. package/dist/{remote-extensions-CsI_BJ_f.js → remote-extensions-CeywZL-Z.js} +4 -4
  25. package/dist/{remote-language-server-Dx13Mg2m.js → remote-language-server-CH8sG_CU.js} +6 -6
  26. package/dist/{remote-uninstall-Dp2o3qz0.js → remote-uninstall-Cq-lkBY7.js} +7 -7
  27. package/dist/{request-origin-CNSLVn7x.js → request-origin-CargJFvH.js} +14 -3
  28. package/dist/{review-app-launcher-BZAnR8Vp.js → review-app-launcher-D8ApNDUV.js} +3 -3
  29. package/dist/{review-home-paths-CG-sb9Cw.js → review-home-paths-DcB4xLD-.js} +2 -2
  30. package/dist/{review-telemetry-BM-_KdLi.js → review-telemetry-BW7gJrSo.js} +7 -7
  31. package/dist/{runtime-C6_1K-3Z.js → runtime-DY6Ctyog.js} +1 -1
  32. package/dist/runtime.js +1 -1
  33. package/dist/{s3-CsG2EWED.js → s3-BIWv4h1J.js} +2 -2
  34. package/dist/{s3-config-zz4GWh2T.js → s3-config-Du3RBIvv.js} +1 -1
  35. package/dist/server/desktop-host.js +51 -17
  36. package/dist/{server-discovery-DKLIOgg3.js → server-discovery-aHK_1r3U.js} +5 -5
  37. package/dist/sharing/index.d.ts +209 -98
  38. package/dist/sharing/index.js +1 -1
  39. package/dist/{src-DYHVzX3b.js → src-DEOc8bvC.js} +63 -2
  40. package/dist/{src-Z1x4-Qmb.js → src-DjRaCctk.js} +6 -6
  41. package/dist/{stored-document-migration-DEt8iwem.js → stored-document-migration-CwmTwTb8.js} +139 -13
  42. package/dist/{thread-state-CO97FUqK.js → thread-state-CbLB0jUA.js} +1 -1
  43. package/dist/{tool-failure-C1L1vJyz.js → tool-failure-DZaQzi4f.js} +2 -2
  44. package/instructions/animation.md +345 -0
  45. package/instructions/authoring.md +2 -2
  46. package/package.json +6 -6
  47. package/src/fixtures/blocks/animation.json +19 -0
  48. package/src/review-api/authoring-tools.ts +17 -6
  49. package/src/review-api/blocks/animation.ts +98 -0
  50. package/src/review-api/blocks/index.ts +3 -0
  51. package/src/review-api/blocks/sequence.ts +3 -1
  52. package/src/review-api/comparison-coverage.ts +120 -89
  53. package/src/review-api/component-reference.ts +16 -0
  54. package/src/review-api/document-text.ts +11 -0
  55. package/src/review-api/document.ts +26 -5
  56. package/src/review-api/http.ts +31 -2
  57. package/src/review-api/instructions.ts +11 -2
  58. package/src/review-api/local-data.ts +21 -4
  59. package/src/review-api/mcp.ts +22 -14
  60. package/src/review-api/onboarding.ts +100 -26
  61. package/src/review-preferences.ts +43 -0
  62. package/src/server/desktop-server.ts +24 -0
  63. package/src/server/json-review-reporting.ts +1 -1
  64. package/src/server/review-server-core.ts +2 -0
  65. package/src/server/structural-comparisons.ts +5 -11
  66. package/src/server/structural-diff.ts +8 -2
  67. package/src/sharing/cli.ts +1 -1
  68. package/src/sharing/client.ts +54 -222
  69. package/src/sharing/export.ts +172 -114
  70. package/src/sharing/host.ts +32 -36
  71. package/src/sharing/import.ts +93 -147
  72. package/src/sharing/index.ts +1 -6
  73. package/src/sharing/routes.ts +18 -17
  74. package/src/ui-telemetry-events.ts +11 -0
  75. package/dist/agent-cli-Cn4-16XA.js +0 -2
  76. package/dist/profile-BopGMZUh.js +0 -2
  77. package/dist/{s3-config-CjcLyFV5.js → s3-config-D7Yt0VhS.js} +1 -1
  78. package/dist/{src-X9ZebeOD.js → src-DXnn7RrI.js} +1 -1
@@ -1,18 +1,19 @@
1
- import { createHash } from "node:crypto";
2
-
3
1
  import {
4
2
  MAX_SHARE_BYTES,
5
- MAX_SHARE_MANIFEST_BYTES,
6
- MAX_SHARE_OBJECT_BYTES,
7
3
  SHARE_FORMAT,
8
- type ShareManifest,
9
- shareManifestSchema,
4
+ type ShareBundle,
5
+ type ShareSource,
10
6
  } from "@dev.fast/review-share-protocol";
11
- import { sourceAnchors } from "@review/lens-selection.js";
7
+ import {
8
+ type LensSource,
9
+ selectionKey,
10
+ sourceAnchors,
11
+ } from "@review/lens-selection.js";
12
12
  import { markdownNodes, parseMarkdown } from "@review/markdown.js";
13
13
  import { lensSelections } from "@review/review-api/diff-lenses.js";
14
- import { ReviewInputError } from "@review/review-api/document.js";
15
14
  import {
15
+ type Pins,
16
+ ReviewInputError,
16
17
  anchorPins,
17
18
  boardRepositoryId,
18
19
  checkReferences,
@@ -21,28 +22,13 @@ import {
21
22
  readsAtBoardPins,
22
23
  resourceReference,
23
24
  resourceReferences,
25
+ selectionReferences,
24
26
  sourceReferences,
25
27
  } from "@review/review-api/document.js";
26
28
  import type { LocalReviewData } from "@review/review-api/local-data.js";
29
+ import { reviewProgress } from "@review/review-api/review-progress.js";
27
30
  import type { ReviewStore, Snapshot } from "@review/review-api/store.js";
28
- import { z } from "zod";
29
-
30
- export interface ShareBundle {
31
- manifest: ShareManifest;
32
- objects: Map<string, Uint8Array>;
33
- attribution?: { login: string; sharedAt: number };
34
- }
35
-
36
- export type ShareExportStore = Pick<ReviewStore, "read" | "resource">;
37
-
38
- export type ShareExportData = Pick<
39
- LocalReviewData,
40
- "map" | "validateResource" | "validateSource"
41
- >;
42
-
43
- export function digestBytes(bytes: Uint8Array): string {
44
- return createHash("sha256").update(bytes).digest("hex");
45
- }
31
+ import type { FileLineRange } from "@review/source.js";
46
32
 
47
33
  export function validateShareSources(
48
34
  snapshot: Pick<Snapshot, "pins" | "document" | "lenses">,
@@ -55,8 +41,8 @@ export function validateShareSources(
55
41
  }
56
42
 
57
43
  /** 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. */
44
+ * Desktop recipient fetches them too. A shared board names no repository of
45
+ * its own, so one stands in for its commit links. */
60
46
  export function referenceCommits(
61
47
  snapshot: Pick<Snapshot, "pins" | "document" | "target">,
62
48
  repositoryId = boardRepositoryId(snapshot) ?? "shared",
@@ -74,13 +60,83 @@ export function referenceCommits(
74
60
  return [...commits].sort();
75
61
  }
76
62
 
63
+ interface ExcerptRange {
64
+ pins: Pins;
65
+ side: "base" | "head";
66
+ file: string;
67
+ fromLine: number;
68
+ toLine: number;
69
+ }
70
+
71
+ /** One excerpt per run of overlapping or adjacent lines in a file at a commit. */
72
+ async function cutExcerpts(
73
+ data: {
74
+ quote(
75
+ pins: Pins,
76
+ range: Omit<ExcerptRange, "pins">,
77
+ ): Promise<{ commit: string; text: string }>;
78
+ },
79
+ ranges: ExcerptRange[],
80
+ ): Promise<ShareSource[]> {
81
+ const groups = new Map<string, ExcerptRange[]>();
82
+
83
+ for (const range of ranges) {
84
+ const key = JSON.stringify([range.pins[range.side], range.file]);
85
+ groups.set(key, [...(groups.get(key) ?? []), range]);
86
+ }
87
+
88
+ const merged = [...groups.values()].flatMap((group) =>
89
+ group
90
+ .toSorted((a, b) => a.fromLine - b.fromLine)
91
+ .reduce<ExcerptRange[]>((runs, range) => {
92
+ const last = runs.at(-1);
93
+
94
+ if (last && range.fromLine <= last.toLine + 1)
95
+ last.toLine = Math.max(last.toLine, range.toLine);
96
+ else runs.push({ ...range });
97
+
98
+ return runs;
99
+ }, []),
100
+ );
101
+
102
+ return Promise.all(
103
+ merged.map(async ({ pins, ...range }) => {
104
+ const quote = await data.quote(pins, range);
105
+
106
+ return {
107
+ kind: "excerpt" as const,
108
+ file: range.file,
109
+ commit: quote.commit,
110
+ fromLine: range.fromLine,
111
+ lines: excerptLines(quote.text),
112
+ };
113
+ }),
114
+ );
115
+ }
116
+
117
+ // jsonb cannot store NUL.
118
+ const excerptLines = (text: string) =>
119
+ text.split(/\r?\n/).map((line) => line.replaceAll("\0", "\uFFFD"));
120
+
121
+ /** The create request body, within the upload limit. */
122
+ export function shareRequestBody(bundle: ShareBundle, requestId: string) {
123
+ const body = JSON.stringify({ requestId, bundle });
124
+
125
+ if (Buffer.byteLength(body) > MAX_SHARE_BYTES)
126
+ throw new ReviewInputError(
127
+ `Review exceeds the ${MAX_SHARE_BYTES / 1024 / 1024} MB sharing limit. Remove large traces, then retry.`,
128
+ );
129
+
130
+ return body;
131
+ }
132
+
77
133
  /** Freeze one stored version before any asynchronous source reads. */
78
134
  export async function exportShare(input: {
79
- store: ShareExportStore;
80
- data: ShareExportData;
135
+ store: ReviewStore;
136
+ data: LocalReviewData;
81
137
  reviewId: string;
82
138
  version?: number;
83
- repository?: ShareManifest["repository"];
139
+ repository?: ShareBundle["repository"];
84
140
  }): Promise<ShareBundle> {
85
141
  const stored = structuredClone(
86
142
  input.store.read(input.reviewId, input.version),
@@ -106,48 +162,8 @@ export async function exportShare(input: {
106
162
 
107
163
  documentSchema.parse(snapshot.document);
108
164
  checkReferences(snapshot.document);
109
- const objects = new Map<string, Uint8Array>();
110
- let totalBytes = 0;
111
-
112
- const add = (bytes: Uint8Array) => {
113
- if (bytes.byteLength > MAX_SHARE_OBJECT_BYTES)
114
- throw new ReviewInputError("A shared object exceeds the upload limit.");
115
- const id = digestBytes(bytes);
116
-
117
- if (!objects.has(id)) {
118
- totalBytes += bytes.byteLength;
119
-
120
- if (totalBytes > MAX_SHARE_BYTES)
121
- throw new ReviewInputError("Review exceeds the sharing limit.");
122
- objects.set(id, Uint8Array.from(bytes));
123
- }
124
-
125
- return id;
126
- };
127
-
128
- const json = <Value>(value: Value) => add(Buffer.from(JSON.stringify(value)));
129
- const resources: ShareManifest["resources"] = [];
130
- const maps: Record<string, Awaited<ReturnType<LocalReviewData["map"]>>> = {};
131
-
132
- // Lens ranges travel with the snapshot and must resolve at its pins too.
133
- const sources = [
134
- ...sourceReferences(snapshot.document, { repositoryId }),
135
- ...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
136
- sourceAnchors(selection.source).map((source) => ({
137
- ...selection,
138
- source,
139
- peek: false,
140
- })),
141
- ),
142
- ];
143
-
144
- const pins = snapshot.pins;
145
-
146
165
  validateShareSources(snapshot);
147
166
 
148
- if ((pins || referenceCommits(stored).length) && !input.repository)
149
- throw new ReviewInputError("Shared source pins require a repository.", 409);
150
-
151
167
  for (const block of elements(snapshot.document)) {
152
168
  if (block.type !== "markdown") continue;
153
169
 
@@ -158,68 +174,110 @@ export async function exportShare(input: {
158
174
  );
159
175
  }
160
176
 
177
+ const pins = snapshot.pins;
178
+
179
+ // Lens ranges travel with the snapshot and must resolve at its pins too.
180
+ const references: {
181
+ source: FileLineRange;
182
+ peek?: boolean;
183
+ }[] = [
184
+ ...sourceReferences(snapshot.document, { repositoryId }),
185
+ ...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
186
+ sourceAnchors(selection.source).map((source) => ({ source })),
187
+ ),
188
+ ];
189
+
190
+ const traces = new Map<string, unknown>();
191
+ const maps = new Map<string, Awaited<ReturnType<LocalReviewData["map"]>>>();
192
+
161
193
  for (const block of resourceReferences(snapshot.document)) {
162
194
  await input.data.validateResource(pins, block);
195
+ const id = resourceReference(block)?.id;
163
196
 
164
- const reference = resourceReference(block);
165
-
166
- if (
167
- !reference ||
168
- resources.some((resource) => resource.id === reference.id)
169
- )
197
+ if (!id || block.type === "image" || traces.has(id) || maps.has(id))
170
198
  continue;
171
- const { id } = reference;
172
- const resource = input.store.resource(id);
173
- resources.push({
174
- id,
175
- kind: z.enum(["image", "trace", "map"]).parse(resource.kind),
176
- mimeType: z
177
- .enum(["image/png", "application/json"])
178
- .parse(resource.mimeType),
179
- object: add(resource.data),
180
- });
181
-
182
- if (block.type === "software_map") {
199
+
200
+ if (block.type === "trace_quote")
201
+ traces.set(
202
+ id,
203
+ JSON.parse(Buffer.from(input.store.resource(id).data).toString()),
204
+ );
205
+ else if (block.type === "software_map") {
183
206
  const map = await input.data.map(pins, id);
184
- Object.defineProperty(maps, id, { value: map, enumerable: true });
207
+ maps.set(id, map);
185
208
 
186
209
  for (const element of map.elements)
187
210
  for (const range of element.sourceRanges ?? [])
188
- sources.push({
189
- id: `${id}:${element.id}`,
190
- source: { ...range, side: map.side },
191
- });
192
- }
211
+ references.push({ source: { ...range, side: map.side } });
212
+ } else throw new ReviewInputError("A shared resource has the wrong type.");
193
213
  }
194
214
 
195
- for (const reference of sources)
215
+ for (const reference of references)
196
216
  await input.data.validateSource(
197
217
  anchorPins(reference.source, pins),
198
218
  reference.source,
199
219
  { peek: reference.peek ?? false },
200
220
  );
201
221
 
202
- const snapshotId = json(snapshot);
203
- const presentationId = json({ maps });
222
+ const { resolvedSelections } = await reviewProgress(
223
+ input.store,
224
+ input.data,
225
+ stored,
226
+ );
204
227
 
205
- const manifest = shareManifestSchema.parse({
228
+ const selected = new Map<string, LensSource>(
229
+ [
230
+ ...lensSelections(snapshot.lenses ?? []),
231
+ ...selectionReferences(snapshot.document, {
232
+ tolerant: true,
233
+ repositoryId,
234
+ }),
235
+ ].map(({ source }) => [selectionKey(source), source]),
236
+ );
237
+
238
+ const selections = Object.fromEntries(
239
+ Object.entries(resolvedSelections).map(([key, ranges]) => {
240
+ const own = selected.get(key)?.pins;
241
+
242
+ return [
243
+ key,
244
+ ranges.map((range) => ({ ...range, ...(own && { pins: own }) })),
245
+ ];
246
+ }),
247
+ );
248
+
249
+ const excerpts: ExcerptRange[] = [
250
+ ...Object.values(selections).flat(),
251
+ ...[...maps.values()].flatMap((map) =>
252
+ map.elements.flatMap((element) =>
253
+ (element.sourceRanges ?? []).map((range) => ({
254
+ ...range,
255
+ side: map.side,
256
+ })),
257
+ ),
258
+ ),
259
+ ].map(({ side = "head", file, fromLine, toLine, ...range }) => ({
260
+ pins: anchorPins(range, pins),
261
+ side,
262
+ file,
263
+ fromLine,
264
+ toLine,
265
+ }));
266
+
267
+ return {
206
268
  format: SHARE_FORMAT,
207
269
  reviewId: snapshot.reviewId,
208
270
  version: snapshot.version,
209
271
  title: snapshot.title,
210
- snapshot: snapshotId,
211
- presentation: presentationId,
212
- objects: [...objects].map(([id, bytes]) => ({
213
- id,
214
- sha256: id,
215
- size: bytes.byteLength,
216
- })),
217
- resources,
218
- repository: input.repository,
219
- });
220
-
221
- if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_SHARE_MANIFEST_BYTES)
222
- throw new ReviewInputError("The share manifest exceeds the upload limit.");
223
-
224
- return { manifest, objects };
272
+ snapshot,
273
+ images: {},
274
+ traces: Object.fromEntries(traces),
275
+ maps: Object.fromEntries(maps),
276
+ commits: pins
277
+ ? await input.data.commits((await input.data.resolveSource(stored)).pins)
278
+ : [],
279
+ selections,
280
+ sources: await cutExcerpts(input.data, excerpts),
281
+ ...(input.repository && { repository: input.repository }),
282
+ };
225
283
  }
@@ -17,7 +17,7 @@ import type { Hono } from "hono";
17
17
  import { z } from "zod";
18
18
 
19
19
  import { readSharingAuth } from "./auth.js";
20
- import { ShareAuthError, ShareClient, SharePreflightError } from "./client.js";
20
+ import { ShareAuthError, ShareClient } from "./client.js";
21
21
  import {
22
22
  exportShare,
23
23
  referenceCommits,
@@ -147,7 +147,7 @@ export function mountSharingHost(
147
147
  "This share uses an untrusted service. Sign in to that service before opening its links.",
148
148
  400,
149
149
  );
150
- const id = sharedReviewId(parsed.origin, parsed.shareId);
150
+ const id = sharedReviewId(parsed.origin, parsed.capability);
151
151
 
152
152
  if (!imports.has(id)) {
153
153
  shared.setStatus(id, "downloading");
@@ -160,12 +160,16 @@ export function mountSharingHost(
160
160
  return;
161
161
  }
162
162
 
163
- const bundle = await new ShareClient(parsed.origin).download(
164
- parsed.shareId,
163
+ const { bundle, attribution } = await new ShareClient(
164
+ parsed.origin,
165
+ ).download(parsed.capability);
166
+
167
+ await shared.import(
168
+ parsed.origin,
165
169
  parsed.capability,
170
+ bundle,
171
+ attribution,
166
172
  );
167
-
168
- await shared.import(parsed.origin, parsed.shareId, bundle);
169
173
  })()
170
174
  .catch((error) => {
171
175
  shared.setStatus(
@@ -234,49 +238,42 @@ export function mountSharingPublisher(
234
238
 
235
239
  const readRepository = options.readRepository ?? readShareRepository;
236
240
 
237
- // Unpinned boards name their repository when they can, so receivers can
238
- // group them.
239
- const repository = !root
240
- ? undefined
241
- : snapshot.pins
242
- ? await readRepository(root)
243
- : await (async () => {
244
- try {
245
- return await readRepository(root);
246
- } catch {
247
- return undefined;
248
- }
249
- })();
241
+ const commits = referenceCommits(snapshot);
242
+
243
+ // Desktop imports need the commits on GitHub; without them the share is
244
+ // for the browser only.
245
+ const repository = (async () => {
246
+ if (!root) return undefined;
247
+
248
+ try {
249
+ const repository = await readRepository(root);
250
+
251
+ if (snapshot.pins || commits.length)
252
+ await verifyRepository(root, snapshot.pins, commits);
253
+
254
+ return repository;
255
+ } catch {
256
+ return undefined;
257
+ }
258
+ })();
250
259
 
251
260
  const bundle = await exportShare({
252
261
  store,
253
262
  data,
254
263
  reviewId: input.reviewId,
255
264
  version: snapshot.version,
256
- repository,
257
265
  });
258
266
 
259
- const commits = referenceCommits(snapshot);
267
+ const verified = await repository;
260
268
 
261
- // The commits must be on GitHub for a recipient to fetch them.
262
- const verification =
263
- root && (snapshot.pins || commits.length) && repository
264
- ? verifyRepository(root, snapshot.pins, commits).then(
265
- () => ({ ok: true as const }),
266
- (error: Error) => ({ ok: false as const, error }),
267
- )
268
- : Promise.resolve({ ok: true as const });
269
+ if (verified) bundle.repository = verified;
269
270
 
270
271
  try {
271
272
  const result = await new ShareClient(
272
273
  account.origin,
273
274
  account.token,
274
275
  options.fetch,
275
- ).create(bundle, input.requestId ?? randomUUID(), async () => {
276
- const result = await verification;
277
-
278
- if (!result.ok) throw result.error;
279
- });
276
+ ).create(bundle, input.requestId ?? randomUUID());
280
277
 
281
278
  const url = new URL(result.url);
282
279
 
@@ -297,8 +294,7 @@ export function mountSharingPublisher(
297
294
  version: snapshot.version,
298
295
  });
299
296
  } catch (error) {
300
- if (error instanceof SharePreflightError)
301
- return context.json({ error: error.message }, 422);
297
+ if (error instanceof ReviewInputError) throw error;
302
298
 
303
299
  if (error instanceof ShareAuthError) {
304
300
  // A CI token lives in the environment; only the saved login can go stale.