@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,29 +1,21 @@
1
- import { f as gitAt } from "./src-X9ZebeOD.js";
2
- import { n as isJsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
3
- import "./src-DYHVzX3b.js";
4
- import { f as sourceAnchors } from "./lens-selection-9byzEgvU.js";
5
- import { B as resourceReferences, D as boardRepositoryId, I as pinsSchema, J as checkReferences, K as sourceReferences, R as readsAtBoardPins, S as anchorPins, b as textIncludesQuote, c as traceSchema, et as lensSchema, j as elements, k as documentSchema, lt as parseMarkdown, n as migrateStoredDocument, r as migrateStoredLenses, s as softwareModelDataSchema, st as markdownNodes, t as liftFileLenses, tt as lensSelections, u as pullRequestUrl, z as resourceReference } from "./stored-document-migration-DEt8iwem.js";
1
+ import { f as gitAt } from "./src-DXnn7RrI.js";
2
+ import { u as parseJsonText } from "./src-X9phtB2j.js";
3
+ import { _ as scopedCoverage, f as sourceAnchors, h as emptyCoverage, i as anchorSelection, l as resolveDiffSelection, m as coverageSources, n as SelectionUnavailableError, o as comparisonKey, u as selectionKey, v as subtractIntervals, y as unionIntervals } from "./lens-selection-EuZdag4Q.js";
4
+ import { $ as UNCATEGORIZED_LENS_ID, B as readsAtBoardPins, H as resourceReferences, J as sourceReferences, N as elements, R as pinsSchema, V as resourceReference, X as checkReferences, b as textIncludesQuote, c as traceSchema, dt as parseMarkdown, j as documentSchema, k as boardRepositoryId, lt as markdownNodes, n as migrateStoredDocument, nt as lensSchema, ot as fileLineRangeSchema, q as selectionReferences, r as migrateStoredLenses, rt as lensSelections, s as softwareModelDataSchema, t as liftFileLenses, u as pullRequestUrl, w as anchorPins, x as textualRows } from "./stored-document-migration-CwmTwTb8.js";
6
5
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
7
6
  import { createHash, randomUUID } from "node:crypto";
8
7
  import { z } from "zod";
8
+ import path, { posix } from "node:path";
9
9
  import { existsSync } from "node:fs";
10
10
  import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
11
- import path from "node:path";
12
11
  import { tmpdir } from "node:os";
13
12
  //#region ../review-share-protocol/src/index.ts
14
- const SHARE_FORMAT = "review-share/1";
15
- const MAX_SHARE_MANIFEST_BYTES = 2097152;
16
- const MAX_SHARE_OBJECT_BYTES = 33554432;
17
- const MAX_SHARE_OBJECTS = 2048;
18
- const objectIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
13
+ const SHARE_FORMAT = "review-share/2";
14
+ /** UTF-8 bytes of a bundle's JSON, as uploaded and as stored. */
15
+ const MAX_SHARE_BYTES = 16777216;
19
16
  const shareIdSchema = z.uuid();
20
17
  const capabilitySchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
21
- const shareObjectSchema = z.strictObject({
22
- id: objectIdSchema,
23
- size: z.number().int().min(0).max(MAX_SHARE_OBJECT_BYTES),
24
- sha256: objectIdSchema
25
- }).refine((object) => object.id === object.sha256, "Object ID must match its digest.");
26
- z.string().min(1).max(4096).refine((file) => !file.startsWith("/") && !file.includes("\\") && !/[\u0000-\u001f\u007f]/.test(file) && file.split("/").every((part) => part !== "" && part !== "." && part !== ".."), "Use a repository-relative file path.");
18
+ const sharePathSchema = z.string().min(1).max(4096).refine((file) => !file.startsWith("/") && !file.includes("\\") && !/[\u0000-\u001f\u007f]/.test(file) && file.split("/").every((part) => part !== "" && part !== "." && part !== ".."), "Use a repository-relative file path.");
27
19
  /** The published identity is independent of the author's credential transport. */
28
20
  function normalizeGitHubRemote(remote) {
29
21
  const url = new URL(remote.trim().replace(/^git@github\.com:/, "https://github.com/"));
@@ -39,56 +31,53 @@ const gitHubRepositoryUrlSchema = z.string().refine((value) => {
39
31
  return false;
40
32
  }
41
33
  }, "Use a canonical GitHub HTTPS repository URL without credentials.");
42
- const shareManifestSchema = z.strictObject({
34
+ const commitSchema = z.string().regex(/^[a-f0-9]{40}([a-f0-9]{24})?$/);
35
+ const sideSchema = z.enum(["base", "head"]);
36
+ /** An excerpt is the lines a board references. */
37
+ const shareSourceSchema = z.discriminatedUnion("kind", [z.strictObject({
38
+ kind: z.literal("excerpt"),
39
+ file: sharePathSchema,
40
+ commit: commitSchema,
41
+ fromLine: z.number().int().positive(),
42
+ lines: z.array(z.string()).min(1)
43
+ })]);
44
+ const lineRangeSchema = z.strictObject({
45
+ side: sideSchema,
46
+ file: sharePathSchema,
47
+ fromLine: z.number().int().positive(),
48
+ toLine: z.number().int().positive(),
49
+ pins: z.strictObject({
50
+ repositoryId: z.string(),
51
+ head: z.string(),
52
+ base: z.string().optional()
53
+ }).optional()
54
+ });
55
+ const shareBundleSchema = z.strictObject({
43
56
  format: z.literal(SHARE_FORMAT),
44
57
  reviewId: z.string().min(1).max(256),
45
58
  version: z.number().int().nonnegative(),
46
59
  title: z.string().min(1).max(4096),
47
- snapshot: objectIdSchema,
48
- presentation: objectIdSchema,
49
- objects: z.array(shareObjectSchema).min(2).max(MAX_SHARE_OBJECTS),
50
- resources: z.array(z.strictObject({
51
- id: z.string().min(1).max(256),
52
- kind: z.enum([
53
- "image",
54
- "trace",
55
- "map"
56
- ]),
57
- mimeType: z.enum(["image/png", "application/json"]),
58
- object: objectIdSchema
59
- })).max(MAX_SHARE_OBJECTS),
60
+ /** Validated against the document schema by the reader. */
61
+ snapshot: z.record(z.string(), z.unknown()),
62
+ images: z.record(z.string(), z.strictObject({ png: z.base64() })),
63
+ traces: z.record(z.string(), z.unknown()),
64
+ maps: z.record(z.string(), z.unknown()),
65
+ commits: z.array(z.unknown()),
66
+ /** Each reference's lines, resolved where the board was shared. */
67
+ selections: z.record(z.string(), z.array(lineRangeSchema)),
68
+ sources: z.array(shareSourceSchema),
60
69
  repository: z.strictObject({ cloneUrl: gitHubRepositoryUrlSchema }).optional()
61
- }).superRefine((manifest, context) => {
62
- const ids = new Set(manifest.objects.map((object) => object.id));
63
- const fail = (message) => context.addIssue({
64
- code: "custom",
65
- message
66
- });
67
- if (ids.size !== manifest.objects.length) fail("Duplicate object ID.");
68
- if (manifest.objects.reduce((size, object) => size + object.size, 0) > 134217728) fail("Share exceeds the total byte limit.");
69
- for (const id of [
70
- manifest.snapshot,
71
- manifest.presentation,
72
- ...manifest.resources.map((resource) => resource.object)
73
- ]) if (!ids.has(id)) fail("Reference to an undeclared object.");
74
- if (new Set(manifest.resources.map((resource) => resource.id)).size !== manifest.resources.length) fail("Duplicate resource ID.");
75
- for (const resource of manifest.resources) if (resource.kind === "image" !== (resource.mimeType === "image/png")) fail("Resource MIME type does not match its kind.");
76
70
  });
77
71
  z.strictObject({
78
72
  requestId: z.uuid(),
79
- manifest: z.strictObject({
80
- sha256: objectIdSchema,
81
- size: z.number().int().positive().max(MAX_SHARE_MANIFEST_BYTES)
82
- })
73
+ bundle: shareBundleSchema
83
74
  });
84
75
  function parseShareLink(value) {
85
76
  const url = new URL(value);
86
- if (url.protocol !== "https:" || url.username || url.password || url.search) throw new Error("Use an HTTPS Review share link.");
87
- const match = /^\/s\/([^/]+)$/.exec(url.pathname);
77
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || [...url.searchParams.keys()].some((key) => key !== "app")) throw new Error("Use an HTTPS Whiteboard share link.");
88
78
  return {
89
79
  origin: url.origin,
90
- shareId: shareIdSchema.parse(match?.[1]),
91
- capability: capabilitySchema.parse(url.hash.slice(1))
80
+ capability: capabilitySchema.parse(/^\/s\/([^/]+)$/.exec(url.pathname)?.[1])
92
81
  };
93
82
  }
94
83
  //#endregion
@@ -119,16 +108,251 @@ async function readBoundedStream(stream, limit) {
119
108
  }
120
109
  }
121
110
  //#endregion
122
- //#region src/sharing/export.ts
123
- function digestBytes(bytes) {
124
- return createHash("sha256").update(bytes).digest("hex");
111
+ //#region src/review-api/file-lenses.ts
112
+ /** Subtract authored coverage, independently of viewed state. */
113
+ function uncategorizedSources(files, sources) {
114
+ return files.flatMap((file) => {
115
+ const covered = scopedCoverage(file, sources);
116
+ return coverageSources(file, {
117
+ base: subtractIntervals(file.changed.base, covered.base),
118
+ head: subtractIntervals(file.changed.head, covered.head)
119
+ });
120
+ });
121
+ }
122
+ /** Resolve all targets into one union; a whole-file range subsumes narrower ranges. */
123
+ function resolveFileLens(lens, files, fileSources, resolve) {
124
+ const targets = lens.targets;
125
+ const selected = targets.flatMap((target) => target.kind === "ranges" ? target.sources.flatMap((source) => resolve(anchorSelection(source))) : files.filter((file) => matchesFileLens(target.patterns, file)).flatMap((file) => fileSources.get(file.path) ?? []));
126
+ const groups = /* @__PURE__ */ new Map();
127
+ for (const source of selected) {
128
+ const key = JSON.stringify([source.side, source.file]);
129
+ const group = groups.get(key) ?? [];
130
+ group.push(source);
131
+ groups.set(key, group);
132
+ }
133
+ const sources = [...groups.values()].flatMap((group) => unionIntervals(group.map((source) => [source.fromLine - 1, source.toLine])).map(([start, end]) => ({
134
+ ...group[0],
135
+ fromLine: start + 1,
136
+ toLine: end
137
+ })));
138
+ return {
139
+ sources,
140
+ fileCount: new Set(sources.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
141
+ wholeFiles: targets.every((target) => target.kind === "files")
142
+ };
143
+ }
144
+ /** Evaluate authored patterns against the changed-file list, never the filesystem. */
145
+ function matchesFileLens(patterns, file) {
146
+ return patterns.some((pattern) => {
147
+ const normalized = pattern.startsWith("./") ? pattern.slice(2) : pattern;
148
+ return [file.path, file.previousPath].some((path) => path !== void 0 && (path === normalized || posix.matchesGlob(path, normalized)));
149
+ });
125
150
  }
151
+ //#endregion
152
+ //#region src/review-api/review-progress.ts
153
+ /** Older clients still send `mode`. */
154
+ const legacyModeSchema = z.unknown().optional();
155
+ const progressUpdateSchema = z.strictObject({
156
+ mode: legacyModeSchema,
157
+ version: z.number().int().nonnegative(),
158
+ files: z.array(z.strictObject({
159
+ path: z.string(),
160
+ fingerprint: z.string().min(1),
161
+ sources: z.array(fileLineRangeSchema)
162
+ })).max(1e4),
163
+ viewed: z.boolean()
164
+ });
165
+ /** Coverage survives new pins only when both complete file contents are unchanged. */
166
+ async function reviewProgress(store, data, snapshot, signal = new AbortController().signal, partial) {
167
+ const marks = snapshot.shared ? /* @__PURE__ */ new Map() : store.viewedCoverage(snapshot.reviewId);
168
+ const pins = snapshot.pins ? (await data.resolveSource(snapshot)).pins : void 0;
169
+ const comparison = pins ? partial ?? await data.coverage(snapshot.reviewId, pins) : {
170
+ counted: true,
171
+ files: [],
172
+ fileSources: /* @__PURE__ */ new Map(),
173
+ alignments: /* @__PURE__ */ new Map()
174
+ };
175
+ const counting = !comparison.counted;
176
+ signal.throwIfAborted();
177
+ const documentKey = JSON.stringify(pins);
178
+ const comparisons = /* @__PURE__ */ new Map();
179
+ /** The comparison a reference's own pins name, shared across references. */
180
+ const ownComparison = (own) => {
181
+ const key = JSON.stringify(own);
182
+ let loading = comparisons.get(key);
183
+ if (loading) return loading;
184
+ if (partial) {
185
+ const state = data.coverageSnapshot(snapshot.reviewId, own);
186
+ if (state.pending) return state.comparison.counted ? Promise.resolve(state.comparison) : void 0;
187
+ }
188
+ loading = data.coverage(snapshot.reviewId, own);
189
+ comparisons.set(key, loading);
190
+ return loading;
191
+ };
192
+ const withViewed = (file) => ({
193
+ ...file,
194
+ viewed: marks.get(file.path)?.fingerprint === file.fingerprint ? marks.get(file.path).coverage : emptyCoverage()
195
+ });
196
+ const files = comparison.files.map(withViewed);
197
+ const referenceFiles = {};
198
+ const fileSources = new Map(comparison.fileSources);
199
+ const alignments = new Map(comparison.alignments);
200
+ const resolvedSelections = {};
201
+ const unavailableSelections = {};
202
+ const pendingSources = /* @__PURE__ */ new Set();
203
+ const resolve = async (source) => {
204
+ const key = selectionKey(source);
205
+ if (resolvedSelections[key]) return resolvedSelections[key];
206
+ const sourcePins = anchorPins(source, pins);
207
+ const own = JSON.stringify(sourcePins) !== documentKey;
208
+ let ownFiles = files;
209
+ let ownAlignments = alignments;
210
+ if (own) {
211
+ const loading = ownComparison(sourcePins);
212
+ if (!loading) {
213
+ pendingSources.add(key);
214
+ return [];
215
+ }
216
+ const loaded = await loading;
217
+ ownFiles = loaded.files;
218
+ ownAlignments = loaded.alignments;
219
+ }
220
+ const file = ownFiles.find((file) => source.file === file.path || source.file === file.previousPath);
221
+ if (own) {
222
+ const group = referenceFiles[comparisonKey(sourcePins)] ??= {};
223
+ if (file) group[file.path] ??= withViewed(file);
224
+ }
225
+ let rows = file && ownAlignments.get(file.path);
226
+ if (!file && counting && !own) {
227
+ pendingSources.add(key);
228
+ return [];
229
+ }
230
+ if (!file) {
231
+ const [base, head] = await Promise.all(["base", "head"].map((side) => data.file(sourcePins, side, source.file).then((value) => value.text, (error) => {
232
+ if (error instanceof ReviewInputError && error.status === 404) return null;
233
+ throw error;
234
+ })));
235
+ if (base === null && head === null) throw new SelectionUnavailableError("missing_file", "File is unavailable at these pins.");
236
+ if (base !== null && head !== null && base !== head) throw new SelectionUnavailableError("no_alignment", "Changed context file has no alignment.");
237
+ rows = textualRows(source.file, "", base?.split("\n").length ?? 0, head?.split("\n").length ?? 0);
238
+ }
239
+ if (!rows) throw new SelectionUnavailableError("no_alignment", "File has no text alignment.");
240
+ const result = resolveDiffSelection(source, rows, file ?? { path: source.file });
241
+ resolvedSelections[key] = result;
242
+ return result;
243
+ };
244
+ const resolveAvailable = async (source) => {
245
+ try {
246
+ return await resolve(source);
247
+ } catch (error) {
248
+ unavailableSelections[selectionKey(source)] = error instanceof SelectionUnavailableError ? error.reason : "other";
249
+ return [];
250
+ }
251
+ };
252
+ const lenses = await Promise.all((snapshot.lenses ?? []).map(async (authored) => {
253
+ const lens = {
254
+ id: authored.id,
255
+ title: authored.title
256
+ };
257
+ try {
258
+ await Promise.all(lensSelections([authored]).map(({ source }) => resolveAvailable(source)));
259
+ const resolved = resolveFileLens(authored, files, fileSources, (source) => resolvedSelections[selectionKey(source)] ?? []);
260
+ return {
261
+ ...lens,
262
+ ...resolved,
263
+ pending: counting,
264
+ unavailable: counting || resolved.fileCount ? void 0 : "No files match these targets"
265
+ };
266
+ } catch {
267
+ return {
268
+ ...lens,
269
+ sources: [],
270
+ unavailable: "Source links need updating at these pins"
271
+ };
272
+ }
273
+ }));
274
+ await Promise.all(selectionReferences(snapshot.document, {
275
+ tolerant: true,
276
+ repositoryId: boardRepositoryId(snapshot)
277
+ }).map(async ({ source }) => {
278
+ try {
279
+ await resolveAvailable(source);
280
+ } catch {}
281
+ }));
282
+ const uncategorized = counting ? [] : uncategorizedSources(files, lenses.filter((lens) => !lens.unavailable).flatMap((lens) => lens.sources));
283
+ lenses.push({
284
+ id: UNCATEGORIZED_LENS_ID,
285
+ title: "Uncategorized changes",
286
+ sources: uncategorized,
287
+ wholeFiles: false,
288
+ fileCount: new Set(uncategorized.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
289
+ pending: counting,
290
+ unavailable: counting || uncategorized.length ? void 0 : "All changed lines are covered by lenses"
291
+ });
292
+ const progress = {
293
+ complete: !counting && !pendingSources.size,
294
+ files,
295
+ referenceFiles,
296
+ lenses,
297
+ resolvedSelections,
298
+ unavailableSelections
299
+ };
300
+ const untrackedFiles = pins ? await data.untrackedFiles(pins) : void 0;
301
+ if (untrackedFiles) progress.untrackedFiles = untrackedFiles;
302
+ return progress;
303
+ }
304
+ /** The most files an uncategorized report names; the rest are counted. */
305
+ const REPORT_FILES = 50;
306
+ /** What a lens author still has to place: the changed lines no lens selects,
307
+ * grouped by file. */
308
+ function uncategorizedReport(progress) {
309
+ const sources = progress.lenses.find((lens) => lens.id === "automatic-uncategorized")?.sources ?? [];
310
+ const byPath = /* @__PURE__ */ new Map();
311
+ for (const source of sources) {
312
+ const path = progress.files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file;
313
+ const entry = byPath.get(path) ?? {
314
+ path,
315
+ lines: 0,
316
+ ranges: []
317
+ };
318
+ entry.lines += source.toLine - source.fromLine + 1;
319
+ entry.ranges.push({
320
+ side: source.side,
321
+ fromLine: source.fromLine,
322
+ toLine: source.toLine
323
+ });
324
+ byPath.set(path, entry);
325
+ }
326
+ const files = [...byPath.values()];
327
+ const report = {
328
+ lines: files.reduce((total, file) => total + file.lines, 0),
329
+ files: files.slice(0, REPORT_FILES)
330
+ };
331
+ if (files.length > REPORT_FILES) report.moreFiles = files.length - REPORT_FILES;
332
+ return report;
333
+ }
334
+ /** Each lens as authored, with what it resolves to, for a lens author. */
335
+ function lensReport(lenses, progress) {
336
+ return {
337
+ lenses: lenses.map((lens) => {
338
+ const resolved = progress.lenses.find((item) => item.id === lens.id);
339
+ return {
340
+ ...lens,
341
+ fileCount: resolved?.fileCount ?? 0,
342
+ ...resolved?.unavailable && { unavailable: resolved.unavailable }
343
+ };
344
+ }),
345
+ uncategorized: uncategorizedReport(progress)
346
+ };
347
+ }
348
+ //#endregion
349
+ //#region src/sharing/export.ts
126
350
  function validateShareSources(snapshot) {
127
351
  if (!snapshot.pins && readsAtBoardPins(snapshot)) throw new ReviewInputError("Reviews with code references need source pins before sharing.", 409);
128
352
  }
129
353
  /** The commits a board's references read at besides its base and head. A
130
- * recipient fetches them too. A shared board names no repository of its
131
- * own, so one stands in for its commit links. */
354
+ * Desktop recipient fetches them too. A shared board names no repository of
355
+ * its own, so one stands in for its commit links. */
132
356
  function referenceCommits(snapshot, repositoryId = boardRepositoryId(snapshot) ?? "shared") {
133
357
  const own = /* @__PURE__ */ new Set([snapshot.pins?.base, snapshot.pins?.head]);
134
358
  const commits = /* @__PURE__ */ new Set();
@@ -138,6 +362,40 @@ function referenceCommits(snapshot, repositoryId = boardRepositoryId(snapshot) ?
138
362
  })) for (const commit of [source.pins?.base, source.pins?.head]) if (commit && !own.has(commit)) commits.add(commit);
139
363
  return [...commits].sort();
140
364
  }
365
+ /** One excerpt per run of overlapping or adjacent lines in a file at a commit. */
366
+ async function cutExcerpts(data, ranges) {
367
+ const groups = /* @__PURE__ */ new Map();
368
+ for (const range of ranges) {
369
+ const key = JSON.stringify([range.pins[range.side], range.file]);
370
+ groups.set(key, [...groups.get(key) ?? [], range]);
371
+ }
372
+ const merged = [...groups.values()].flatMap((group) => group.toSorted((a, b) => a.fromLine - b.fromLine).reduce((runs, range) => {
373
+ const last = runs.at(-1);
374
+ if (last && range.fromLine <= last.toLine + 1) last.toLine = Math.max(last.toLine, range.toLine);
375
+ else runs.push({ ...range });
376
+ return runs;
377
+ }, []));
378
+ return Promise.all(merged.map(async ({ pins, ...range }) => {
379
+ const quote = await data.quote(pins, range);
380
+ return {
381
+ kind: "excerpt",
382
+ file: range.file,
383
+ commit: quote.commit,
384
+ fromLine: range.fromLine,
385
+ lines: excerptLines(quote.text)
386
+ };
387
+ }));
388
+ }
389
+ const excerptLines = (text) => text.split(/\r?\n/).map((line) => line.replaceAll("\0", "�"));
390
+ /** The create request body, within the upload limit. */
391
+ function shareRequestBody(bundle, requestId) {
392
+ const body = JSON.stringify({
393
+ requestId,
394
+ bundle
395
+ });
396
+ if (Buffer.byteLength(body) > 16777216) throw new ReviewInputError(`Review exceeds the ${MAX_SHARE_BYTES / 1024 / 1024} MB sharing limit. Remove large traces, then retry.`);
397
+ return body;
398
+ }
141
399
  /** Freeze one stored version before any asynchronous source reads. */
142
400
  async function exportShare(input) {
143
401
  const stored = structuredClone(input.store.read(input.reviewId, input.version));
@@ -146,89 +404,162 @@ async function exportShare(input) {
146
404
  if (target?.kind === "worktree") throw new ReviewInputError("Pin this review to commits before sharing it.");
147
405
  documentSchema.parse(snapshot.document);
148
406
  checkReferences(snapshot.document);
149
- const objects = /* @__PURE__ */ new Map();
150
- let totalBytes = 0;
151
- const add = (bytes) => {
152
- if (bytes.byteLength > 33554432) throw new ReviewInputError("A shared object exceeds the upload limit.");
153
- const id = digestBytes(bytes);
154
- if (!objects.has(id)) {
155
- totalBytes += bytes.byteLength;
156
- if (totalBytes > 134217728) throw new ReviewInputError("Review exceeds the sharing limit.");
157
- objects.set(id, Uint8Array.from(bytes));
158
- }
159
- return id;
160
- };
161
- const json = (value) => add(Buffer.from(JSON.stringify(value)));
162
- const resources = [];
163
- const maps = {};
164
- const sources = [...sourceReferences(snapshot.document, { repositoryId }), ...lensSelections(snapshot.lenses ?? []).flatMap((selection) => sourceAnchors(selection.source).map((source) => ({
165
- ...selection,
166
- source,
167
- peek: false
168
- })))];
169
- const pins = snapshot.pins;
170
407
  validateShareSources(snapshot);
171
- if ((pins || referenceCommits(stored).length) && !input.repository) throw new ReviewInputError("Shared source pins require a repository.", 409);
172
408
  for (const block of elements(snapshot.document)) {
173
409
  if (block.type !== "markdown") continue;
174
410
  for (const node of markdownNodes(parseMarkdown(block.markdown))) if (node.type === "image") throw new ReviewInputError("Convert Markdown images to managed image blocks before sharing.");
175
411
  }
412
+ const pins = snapshot.pins;
413
+ const references = [...sourceReferences(snapshot.document, { repositoryId }), ...lensSelections(snapshot.lenses ?? []).flatMap((selection) => sourceAnchors(selection.source).map((source) => ({ source })))];
414
+ const traces = /* @__PURE__ */ new Map();
415
+ const maps = /* @__PURE__ */ new Map();
176
416
  for (const block of resourceReferences(snapshot.document)) {
177
417
  await input.data.validateResource(pins, block);
178
- const reference = resourceReference(block);
179
- if (!reference || resources.some((resource) => resource.id === reference.id)) continue;
180
- const { id } = reference;
181
- const resource = input.store.resource(id);
182
- resources.push({
183
- id,
184
- kind: z.enum([
185
- "image",
186
- "trace",
187
- "map"
188
- ]).parse(resource.kind),
189
- mimeType: z.enum(["image/png", "application/json"]).parse(resource.mimeType),
190
- object: add(resource.data)
191
- });
192
- if (block.type === "software_map") {
418
+ const id = resourceReference(block)?.id;
419
+ if (!id || block.type === "image" || traces.has(id) || maps.has(id)) continue;
420
+ if (block.type === "trace_quote") traces.set(id, JSON.parse(Buffer.from(input.store.resource(id).data).toString()));
421
+ else if (block.type === "software_map") {
193
422
  const map = await input.data.map(pins, id);
194
- Object.defineProperty(maps, id, {
195
- value: map,
196
- enumerable: true
197
- });
198
- for (const element of map.elements) for (const range of element.sourceRanges ?? []) sources.push({
199
- id: `${id}:${element.id}`,
200
- source: {
201
- ...range,
202
- side: map.side
203
- }
204
- });
205
- }
423
+ maps.set(id, map);
424
+ for (const element of map.elements) for (const range of element.sourceRanges ?? []) references.push({ source: {
425
+ ...range,
426
+ side: map.side
427
+ } });
428
+ } else throw new ReviewInputError("A shared resource has the wrong type.");
206
429
  }
207
- for (const reference of sources) await input.data.validateSource(anchorPins(reference.source, pins), reference.source, { peek: reference.peek ?? false });
208
- const snapshotId = json(snapshot);
209
- const presentationId = json({ maps });
210
- const manifest = shareManifestSchema.parse({
430
+ for (const reference of references) await input.data.validateSource(anchorPins(reference.source, pins), reference.source, { peek: reference.peek ?? false });
431
+ const { resolvedSelections } = await reviewProgress(input.store, input.data, stored);
432
+ const selected = new Map([...lensSelections(snapshot.lenses ?? []), ...selectionReferences(snapshot.document, {
433
+ tolerant: true,
434
+ repositoryId
435
+ })].map(({ source }) => [selectionKey(source), source]));
436
+ const selections = Object.fromEntries(Object.entries(resolvedSelections).map(([key, ranges]) => {
437
+ const own = selected.get(key)?.pins;
438
+ return [key, ranges.map((range) => ({
439
+ ...range,
440
+ ...own && { pins: own }
441
+ }))];
442
+ }));
443
+ const excerpts = [...Object.values(selections).flat(), ...[...maps.values()].flatMap((map) => map.elements.flatMap((element) => (element.sourceRanges ?? []).map((range) => ({
444
+ ...range,
445
+ side: map.side
446
+ }))))].map(({ side = "head", file, fromLine, toLine, ...range }) => ({
447
+ pins: anchorPins(range, pins),
448
+ side,
449
+ file,
450
+ fromLine,
451
+ toLine
452
+ }));
453
+ return {
211
454
  format: SHARE_FORMAT,
212
455
  reviewId: snapshot.reviewId,
213
456
  version: snapshot.version,
214
457
  title: snapshot.title,
215
- snapshot: snapshotId,
216
- presentation: presentationId,
217
- objects: [...objects].map(([id, bytes]) => ({
218
- id,
219
- sha256: id,
220
- size: bytes.byteLength
221
- })),
222
- resources,
223
- repository: input.repository
224
- });
225
- if (Buffer.byteLength(JSON.stringify(manifest)) > 2097152) throw new ReviewInputError("The share manifest exceeds the upload limit.");
226
- return {
227
- manifest,
228
- objects
458
+ snapshot,
459
+ images: {},
460
+ traces: Object.fromEntries(traces),
461
+ maps: Object.fromEntries(maps),
462
+ commits: pins ? await input.data.commits((await input.data.resolveSource(stored)).pins) : [],
463
+ selections,
464
+ sources: await cutExcerpts(input.data, excerpts),
465
+ ...input.repository && { repository: input.repository }
229
466
  };
230
467
  }
231
468
  //#endregion
469
+ //#region src/sharing/client.ts
470
+ const MAX_RESPONSE_BYTES = MAX_SHARE_BYTES;
471
+ const linkSchema = z.strictObject({
472
+ shareId: z.uuid(),
473
+ url: z.url()
474
+ });
475
+ const receivedSchema = z.object({
476
+ sender: z.strictObject({ login: z.string().min(1).max(256) }),
477
+ sharedAt: z.number(),
478
+ size: z.number().int().positive().max(MAX_SHARE_BYTES)
479
+ });
480
+ /** Size is checked while reading, even if Content-Length is absent or false. */
481
+ async function readBoundedBytes(response, limit) {
482
+ if (!response.ok || !response.body) throw new Error(`Download failed (${response.status}).`);
483
+ const declared = response.headers.get("content-length");
484
+ if (declared && Number(declared) > limit) {
485
+ await response.body.cancel().catch(() => {});
486
+ throw new Error("Download exceeds its declared limit.");
487
+ }
488
+ try {
489
+ return await readBoundedStream(response.body, limit);
490
+ } catch (error) {
491
+ if (error instanceof StreamLimitError) throw new Error("Download exceeds its declared limit.");
492
+ throw error;
493
+ }
494
+ }
495
+ /** The share service rejected the account token; the stored login is stale. */
496
+ var ShareAuthError = class extends Error {
497
+ constructor() {
498
+ super("The share service rejected the stored login.");
499
+ this.name = "ShareAuthError";
500
+ }
501
+ };
502
+ /** Account tokens go only to the configured API origin. */
503
+ var ShareClient = class {
504
+ token;
505
+ send;
506
+ origin;
507
+ constructor(origin, token, send = fetch) {
508
+ this.token = token;
509
+ this.send = send;
510
+ const url = new URL(origin);
511
+ if (url.username || url.password || url.protocol !== "https:" && !(url.protocol === "http:" && url.hostname === "localhost")) throw new Error("Use a secure share service origin.");
512
+ this.origin = url.origin;
513
+ }
514
+ async api(route, method = "GET", body, anonymous = false) {
515
+ return parseJsonText(Buffer.from(await this.request(route, method, body, anonymous, MAX_RESPONSE_BYTES)).toString());
516
+ }
517
+ async request(route, method, body, anonymous, maxBytes) {
518
+ const headers = new Headers({ "content-type": "application/json" });
519
+ if (this.token && !anonymous) headers.set("authorization", `Bearer ${this.token}`);
520
+ const init = {
521
+ method,
522
+ redirect: "error",
523
+ signal: AbortSignal.timeout(3e5),
524
+ headers
525
+ };
526
+ if (body !== void 0) init.body = body;
527
+ const response = await this.send(this.origin + route, init);
528
+ if (!response.ok) {
529
+ await response.body?.cancel();
530
+ if (response.status === 401 && !anonymous) throw new ShareAuthError();
531
+ throw new Error(`Share request failed (${response.status}).`);
532
+ }
533
+ return readBoundedBytes(response, maxBytes);
534
+ }
535
+ async create(bundle, requestId = randomUUID()) {
536
+ return linkSchema.parse(await this.api("/api/v2/shares", "POST", shareRequestBody(bundle, requestId)));
537
+ }
538
+ async list(cursor) {
539
+ const query = cursor ? `?cursor=${z.uuid().parse(cursor)}` : "";
540
+ return this.api(`/api/v2/shares${query}`);
541
+ }
542
+ async recoverLink(shareId) {
543
+ return linkSchema.parse(await this.api(`/api/v2/shares/${z.uuid().parse(shareId)}/link`));
544
+ }
545
+ async revoke(shareId) {
546
+ return this.api(`/api/v2/shares/${z.uuid().parse(shareId)}`, "DELETE");
547
+ }
548
+ /** The bundle and who shared it, read with the link's capability. */
549
+ async download(capability) {
550
+ const route = `/api/v2/shared/${capabilitySchema.parse(capability)}`;
551
+ const received = receivedSchema.parse(await this.api(route, "GET", void 0, true));
552
+ const bytes = await this.request(`${route}/bundle`, "GET", void 0, true, MAX_SHARE_BYTES);
553
+ return {
554
+ bundle: shareBundleSchema.parse(parseJsonText(new TextDecoder().decode(bytes))),
555
+ attribution: {
556
+ login: received.sender.login,
557
+ sharedAt: received.sharedAt
558
+ }
559
+ };
560
+ }
561
+ };
562
+ //#endregion
232
563
  //#region src/sharing/repository.ts
233
564
  /** A full commit id, SHA-1 or SHA-256. */
234
565
  const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
@@ -513,50 +844,41 @@ const mapSchema = softwareModelDataSchema.extend({
513
844
  }))
514
845
  })) }))
515
846
  });
516
- const sharePresentationSchema = z.strictObject({ maps: z.record(z.string(), mapSchema) });
517
- function validateShareBundle(bundle) {
518
- const manifest = shareManifestSchema.parse(bundle.manifest);
519
- if (Buffer.byteLength(JSON.stringify(manifest)) > 2097152) throw new Error("Share manifest is too large.");
520
- if (bundle.objects.size !== manifest.objects.length) throw new Error("Share object count does not match its manifest.");
521
- for (const object of manifest.objects) {
522
- const bytes = bundle.objects.get(object.id);
523
- if (!bytes || bytes.byteLength !== object.size || digestBytes(bytes) !== object.sha256) throw new Error(`Shared object ${object.id} is missing or corrupt.`);
524
- }
525
- const json = (id) => parseJsonText(Buffer.from(bundle.objects.get(id)).toString());
526
- const stored = json(manifest.snapshot);
527
- const snapshot = sharedSnapshotSchema.parse(isJsonObject(stored) && "document" in stored ? liftSharedLenses(stored, migrateStoredDocument(stored.document)) : stored);
528
- if (snapshot.reviewId !== manifest.reviewId || snapshot.version !== manifest.version || snapshot.title !== manifest.title) throw new Error("Shared snapshot does not match its manifest.");
847
+ z.strictObject({ maps: z.record(z.string(), mapSchema) });
848
+ function validateShareBundle(input) {
849
+ const bundle = shareBundleSchema.parse(input);
850
+ const stored = bundle.snapshot;
851
+ const snapshot = sharedSnapshotSchema.parse(stored.document === void 0 ? stored : liftSharedLenses(stored, migrateStoredDocument(stored.document)));
852
+ if (snapshot.reviewId !== bundle.reviewId || snapshot.version !== bundle.version || snapshot.title !== bundle.title) throw new Error("Shared snapshot does not match its bundle.");
529
853
  checkReferences(snapshot.document);
530
854
  validateShareSources(snapshot);
531
855
  assertCommitIds(referenceCommits(snapshot));
532
- if (snapshot.pins && !manifest.repository) throw new Error("Shared source pins require a repository.");
533
- const presentation = sharePresentationSchema.parse(json(manifest.presentation));
534
- if (!snapshot.pins && Object.keys(presentation.maps).length) throw new Error("Shared maps require source pins.");
856
+ if ((snapshot.pins || referenceCommits(snapshot).length) && !bundle.repository) throw new ReviewInputError("This share can only be viewed in the browser.", 409);
857
+ const maps = z.record(z.string(), mapSchema).parse(bundle.maps);
858
+ if (!snapshot.pins && Object.keys(maps).length) throw new Error("Shared maps require source pins.");
535
859
  const traces = /* @__PURE__ */ new Map();
536
860
  for (const block of resourceReferences(snapshot.document)) {
537
- const reference = resourceReference(block);
538
- const resource = manifest.resources.find((item) => item.id === reference?.id);
539
- if (!reference || !resource || resource.kind !== reference.kind) throw new Error("Missing or mismatched shared resource.");
540
861
  if (block.type === "trace_quote") {
541
- let events = traces.get(resource.object);
862
+ let events = traces.get(block.traceId);
542
863
  if (!events) {
543
- const trace = traceSchema.parse(json(resource.object));
864
+ if (!Object.hasOwn(bundle.traces, block.traceId)) throw new Error("Missing shared trace.");
865
+ const trace = traceSchema.parse(bundle.traces[block.traceId]);
544
866
  events = new Map(trace.events.map((event) => [event.id, event]));
545
867
  if (events.size !== trace.events.length) throw new Error("Duplicate shared trace event.");
546
- traces.set(resource.object, events);
868
+ traces.set(block.traceId, events);
547
869
  }
548
870
  const event = events.get(block.eventId);
549
871
  if (!event || !textIncludesQuote(event.text, block.text)) throw new Error("Shared quote does not match its trace.");
550
872
  }
551
873
  if (block.type === "software_map") {
552
- const map = presentation.maps[block.mapVersionId];
874
+ const map = Object.hasOwn(maps, block.mapVersionId) ? maps[block.mapVersionId] : void 0;
553
875
  if (!map || map.commit !== snapshot.pins?.[map.side]) throw new Error("Shared map pins do not match.");
554
876
  }
555
877
  }
556
878
  return {
557
- manifest,
879
+ bundle,
558
880
  snapshot,
559
- presentation
881
+ maps
560
882
  };
561
883
  }
562
884
  function liftSharedLenses(stored, migrated) {
@@ -568,10 +890,10 @@ function liftSharedLenses(stored, migrated) {
568
890
  ...(kept.length || lenses.length) && { lenses: [...kept, ...lenses] }
569
891
  };
570
892
  }
571
- function sharedReviewId(origin, shareId) {
893
+ function sharedReviewId(origin, capability) {
572
894
  const url = new URL(origin);
573
- shareIdSchema.parse(shareId);
574
- return `shared-${digestBytes(Buffer.from(JSON.stringify([url.origin, shareId])))}`;
895
+ capabilitySchema.parse(capability);
896
+ return `shared-${createHash("sha256").update(JSON.stringify([url.origin, capability])).digest("hex")}`;
575
897
  }
576
898
  /** Separate immutable directory tree; authoring mutation handlers cannot write it. */
577
899
  var SharedReviewStore = class {
@@ -662,7 +984,7 @@ var SharedReviewStore = class {
662
984
  const saved = this.validated.get(id);
663
985
  if (!saved) throw new ReviewInputError("Shared review is not available.", 404);
664
986
  if (!saved.snapshot.pins) {
665
- await this.prepareUnpinned(id, saved.manifest.repository?.cloneUrl);
987
+ await this.prepareUnpinned(id, saved.bundle.repository?.cloneUrl);
666
988
  return;
667
989
  }
668
990
  if (!local) throw new ReviewInputError("Repository service is unavailable.", 409);
@@ -678,7 +1000,7 @@ var SharedReviewStore = class {
678
1000
  recursive: true,
679
1001
  force: true
680
1002
  });
681
- await this.fetchRepository(root, saved.manifest.repository.cloneUrl, saved.snapshot.pins, false, commits);
1003
+ await this.fetchRepository(root, saved.bundle.repository.cloneUrl, saved.snapshot.pins, false, commits);
682
1004
  await sharedGit(root, [
683
1005
  "checkout",
684
1006
  "--detach",
@@ -728,7 +1050,7 @@ var SharedReviewStore = class {
728
1050
  ...anchorPins(source, pins),
729
1051
  repositoryId
730
1052
  }, source);
731
- if (pins) for (const map of Object.values(saved.presentation.maps)) for (const element of map.elements) for (const range of element.sourceRanges ?? []) add(pins, {
1053
+ if (pins) for (const map of Object.values(saved.maps)) for (const element of map.elements) for (const range of element.sourceRanges ?? []) add(pins, {
732
1054
  ...range,
733
1055
  side: map.side
734
1056
  });
@@ -794,6 +1116,13 @@ var SharedReviewStore = class {
794
1116
  });
795
1117
  for (const id of await readdir(this.root)) {
796
1118
  if (!/^shared-[a-f0-9]{64}$/.test(id)) continue;
1119
+ if (!existsSync(path.join(this.root, id, "bundle.json"))) {
1120
+ await rm(path.join(this.root, id), {
1121
+ recursive: true,
1122
+ force: true
1123
+ });
1124
+ continue;
1125
+ }
797
1126
  let saved;
798
1127
  try {
799
1128
  saved = await this.readValidated(id);
@@ -802,11 +1131,8 @@ var SharedReviewStore = class {
802
1131
  console.warn("A cached shared review was corrupt. Reopen its share link to download it again.");
803
1132
  continue;
804
1133
  }
805
- const { bundle, validated } = saved;
806
- this.loaded.set(id, {
807
- manifest: bundle.manifest,
808
- attribution: bundle.attribution
809
- });
1134
+ const { attribution, validated } = saved;
1135
+ this.loaded.set(id, { attribution });
810
1136
  this.validated.set(id, validated);
811
1137
  let prepared = false;
812
1138
  try {
@@ -820,7 +1146,7 @@ var SharedReviewStore = class {
820
1146
  }
821
1147
  } catch {}
822
1148
  if (!validated.snapshot.pins) {
823
- if (this.repositories.has(id) || !validated.manifest.repository) this.setStatus(id, "ready");
1149
+ if (this.repositories.has(id) || !validated.bundle.repository) this.setStatus(id, "ready");
824
1150
  else this.prepare(id).catch(() => {});
825
1151
  } else if (prepared && this.local && await repositoryReady(this.repositoryRoot(id), validated.snapshot.pins, referenceCommits(validated.snapshot))) {
826
1152
  const repository = await this.local.data.register(this.repositoryRoot(id));
@@ -854,8 +1180,8 @@ var SharedReviewStore = class {
854
1180
  })}. Restore it with session_lifecycle({"sessionId":"${id}","state":"active"}) to continue.`, 409);
855
1181
  }
856
1182
  get(id) {
857
- const bundle = this.loaded.get(id);
858
- if (!bundle) throw new ReviewInputError("Shared review is not available.", 404);
1183
+ const loaded = this.loaded.get(id);
1184
+ if (!loaded) throw new ReviewInputError("Shared review is not available.", 404);
859
1185
  const validated = this.validated.get(id);
860
1186
  const repositoryId = this.repositories.get(id);
861
1187
  if (this.status(id).stage !== "ready" || validated.snapshot.pins && (!repositoryId || !existsSync(path.join(this.repositoryRoot(id), ".git")))) throw new ReviewInputError(this.status(id).error ?? "Fetch the shared repository before opening this review.", 409);
@@ -878,8 +1204,8 @@ var SharedReviewStore = class {
878
1204
  repositoryId
879
1205
  } : void 0,
880
1206
  shared: {
881
- ...bundle.attribution,
882
- cloneUrl: bundle.manifest.repository?.cloneUrl
1207
+ ...loaded.attribution,
1208
+ cloneUrl: validated.bundle.repository?.cloneUrl
883
1209
  }
884
1210
  }
885
1211
  };
@@ -899,15 +1225,6 @@ var SharedReviewStore = class {
899
1225
  }).toArray();
900
1226
  return this.local?.store.withDiffStats(reviews) ?? reviews;
901
1227
  }
902
- async readObject(id, objectId) {
903
- const entry = this.get(id).manifest.objects.find((object) => object.id === objectId);
904
- if (!entry) throw new ReviewInputError("Shared object is unavailable.", 404);
905
- const file = path.join(this.root, id, objectId);
906
- if ((await stat(file)).size !== entry.size) throw new Error("Corrupt cached shared object.");
907
- const bytes = await readFile(file);
908
- if (digestBytes(bytes) !== entry.sha256) throw new Error("Corrupt cached shared object.");
909
- return bytes;
910
- }
911
1228
  async removeLocal(id) {
912
1229
  await this.jobs.get(id)?.catch(() => {});
913
1230
  if (!this.loaded.has(id)) throw new ReviewInputError("Shared review is not available.", 404);
@@ -959,9 +1276,9 @@ var SharedReviewStore = class {
959
1276
  this.attention.set(id, state);
960
1277
  for (const listener of this.listeners) listener();
961
1278
  }
962
- async import(origin, shareId, bundle) {
1279
+ async import(origin, capability, bundle, attribution) {
963
1280
  const validated = validateShareBundle(bundle);
964
- const id = sharedReviewId(origin, shareId);
1281
+ const id = sharedReviewId(origin, capability);
965
1282
  this.assertWritable(id);
966
1283
  await mkdir(this.root, {
967
1284
  recursive: true,
@@ -969,15 +1286,14 @@ var SharedReviewStore = class {
969
1286
  });
970
1287
  const staging = await mkdtemp(path.join(this.root, ".import-"));
971
1288
  try {
972
- await writeFile(path.join(staging, "manifest.json"), JSON.stringify(bundle.manifest), { mode: 384 });
973
- if (bundle.attribution) await writeFile(path.join(staging, "attribution.json"), JSON.stringify(bundle.attribution), { mode: 384 });
974
- for (const [objectId, bytes] of bundle.objects) await writeFile(path.join(staging, objectId), bytes, { mode: 384 });
1289
+ await writeFile(path.join(staging, "bundle.json"), JSON.stringify(validated.bundle), { mode: 384 });
1290
+ if (attribution) await writeFile(path.join(staging, "attribution.json"), JSON.stringify(attribution), { mode: 384 });
975
1291
  try {
976
1292
  await rename(staging, path.join(this.root, id));
977
1293
  } catch (error) {
978
1294
  if (!(error instanceof Error && "code" in error && ["ENOTEMPTY", "EEXIST"].includes(String(error.code)))) throw error;
979
1295
  const existing = await this.read(id);
980
- if (JSON.stringify(existing.manifest) !== JSON.stringify(bundle.manifest)) throw new Error("An immutable share changed its manifest.");
1296
+ if (JSON.stringify(existing) !== JSON.stringify(validated.bundle)) throw new Error("An immutable share changed its bundle.");
981
1297
  }
982
1298
  } finally {
983
1299
  await rm(staging, {
@@ -985,29 +1301,20 @@ var SharedReviewStore = class {
985
1301
  force: true
986
1302
  });
987
1303
  }
988
- this.loaded.set(id, {
989
- manifest: bundle.manifest,
990
- attribution: bundle.attribution
991
- });
1304
+ this.loaded.set(id, { attribution });
992
1305
  this.validated.set(id, validated);
993
1306
  for (const listener of this.listeners) listener();
994
1307
  await this.prepare(id);
995
1308
  return id;
996
1309
  }
997
1310
  async read(id) {
998
- return (await this.readValidated(id)).bundle;
1311
+ return (await this.readValidated(id)).validated.bundle;
999
1312
  }
1000
1313
  async readValidated(id) {
1001
1314
  if (!/^shared-[a-f0-9]{64}$/.test(id)) throw new Error("Invalid shared review ID.");
1002
1315
  const dir = path.join(this.root, id);
1003
- if ((await stat(path.join(dir, "manifest.json"))).size > 2097152) throw new Error("Shared manifest is too large.");
1004
- const manifest = shareManifestSchema.parse(JSON.parse(await readFile(path.join(dir, "manifest.json"), "utf8")));
1005
- const objects = /* @__PURE__ */ new Map();
1006
- for (const object of manifest.objects) {
1007
- const file = path.join(dir, object.id);
1008
- if ((await stat(file)).size !== object.size) throw new Error("Corrupt cached shared object.");
1009
- objects.set(object.id, await readFile(file));
1010
- }
1316
+ const file = path.join(dir, "bundle.json");
1317
+ if ((await stat(file)).size > 16777216) throw new Error("Shared bundle is too large.");
1011
1318
  let attribution;
1012
1319
  try {
1013
1320
  attribution = z.strictObject({
@@ -1015,203 +1322,11 @@ var SharedReviewStore = class {
1015
1322
  sharedAt: z.number()
1016
1323
  }).parse(JSON.parse(await readFile(path.join(dir, "attribution.json"), "utf8")));
1017
1324
  } catch {}
1018
- const bundle = {
1019
- manifest,
1020
- objects,
1021
- attribution
1022
- };
1023
1325
  return {
1024
- bundle,
1025
- validated: validateShareBundle(bundle)
1026
- };
1027
- }
1028
- };
1029
- //#endregion
1030
- //#region src/sharing/client.ts
1031
- const uploadSchema = z.strictObject({
1032
- url: z.url(),
1033
- headers: z.record(z.string(), z.string()),
1034
- expiresAt: z.string()
1035
- });
1036
- const createdSchema = z.strictObject({
1037
- shareId: z.uuid(),
1038
- upload: uploadSchema
1039
- });
1040
- const registeredSchema = z.object({
1041
- registered: z.literal(true),
1042
- uploads: z.record(z.string(), uploadSchema)
1043
- });
1044
- const UPLOAD_CONCURRENCY = 4;
1045
- const MAX_UPLOAD_RESPONSE_BYTES = 8388608;
1046
- const linkSchema = z.strictObject({
1047
- shareId: z.uuid(),
1048
- url: z.url()
1049
- });
1050
- const receivedSchema = z.strictObject({
1051
- manifest: shareManifestSchema,
1052
- sender: z.strictObject({ login: z.string().min(1).max(256) }),
1053
- sharedAt: z.number()
1054
- });
1055
- const signedUrlSchema = z.url().refine((value) => {
1056
- const url = new URL(value);
1057
- return url.protocol === "https:" && !url.username && !url.password;
1058
- });
1059
- const downloadSchema = z.strictObject({
1060
- url: z.url(),
1061
- expiresAt: z.string()
1062
- });
1063
- /** Size is checked while reading, even if Content-Length is absent or false. */
1064
- async function readBoundedBytes(response, limit) {
1065
- if (!response.ok || !response.body) throw new Error(`Download failed (${response.status}).`);
1066
- const declared = response.headers.get("content-length");
1067
- if (declared && Number(declared) > limit) {
1068
- await response.body.cancel().catch(() => {});
1069
- throw new Error("Download exceeds its declared limit.");
1070
- }
1071
- try {
1072
- return await readBoundedStream(response.body, limit);
1073
- } catch (error) {
1074
- if (error instanceof StreamLimitError) throw new Error("Download exceeds its declared limit.");
1075
- throw error;
1076
- }
1077
- }
1078
- /** The share service rejected the account token; the stored login is stale. */
1079
- var ShareAuthError = class extends Error {
1080
- constructor() {
1081
- super("The share service rejected the stored login.");
1082
- this.name = "ShareAuthError";
1083
- }
1084
- };
1085
- var SharePreflightError = class extends Error {
1086
- constructor(cause) {
1087
- super(cause instanceof Error ? cause.message : "Repository verification failed.", { cause });
1088
- this.name = "SharePreflightError";
1089
- }
1090
- };
1091
- var ShareRequestError = class extends Error {
1092
- status;
1093
- constructor(status) {
1094
- super(`Share request failed (${status}).`);
1095
- this.status = status;
1096
- }
1097
- };
1098
- /** Account tokens go only to the configured API origin, never to object storage. */
1099
- var ShareClient = class {
1100
- token;
1101
- send;
1102
- origin;
1103
- constructor(origin, token, send = fetch) {
1104
- this.token = token;
1105
- this.send = send;
1106
- const url = new URL(origin);
1107
- if (url.username || url.password || url.protocol !== "https:" && !(url.protocol === "http:" && url.hostname === "localhost")) throw new Error("Use a secure share service origin.");
1108
- this.origin = url.origin;
1109
- }
1110
- async api(route, method = "GET", body, capability, maxBytes = MAX_SHARE_MANIFEST_BYTES) {
1111
- const headers = new Headers({ "content-type": "application/json" });
1112
- if (capability) headers.set("x-review-share-token", capability);
1113
- else if (this.token) headers.set("authorization", `Bearer ${this.token}`);
1114
- const init = {
1115
- method,
1116
- redirect: "error",
1117
- signal: AbortSignal.timeout(6e4),
1118
- headers
1119
- };
1120
- if (body !== void 0) init.body = JSON.stringify(body);
1121
- const response = await this.send(this.origin + route, init);
1122
- if (!response.ok) {
1123
- await response.body?.cancel();
1124
- if (response.status === 401 && !capability) throw new ShareAuthError();
1125
- throw new ShareRequestError(response.status);
1126
- }
1127
- return parseJsonText(Buffer.from(await readBoundedBytes(response, maxBytes)).toString());
1128
- }
1129
- async upload(upload, bytes) {
1130
- if (!signedUrlSchema.safeParse(upload.url).success) throw new Error("Invalid object upload URL.");
1131
- const response = await this.send(upload.url, {
1132
- method: "PUT",
1133
- headers: upload.headers,
1134
- body: Buffer.from(bytes),
1135
- redirect: "error",
1136
- signal: AbortSignal.timeout(12e4)
1137
- });
1138
- await response.body?.cancel();
1139
- if (!response.ok && response.status !== 412) throw new Error(`Object upload failed (${response.status}).`);
1140
- }
1141
- async create(bundle, requestId = randomUUID(), beforeUpload) {
1142
- try {
1143
- validateShareBundle(bundle);
1144
- } catch (cause) {
1145
- throw new SharePreflightError(new Error("Whiteboard could not prepare this review for sharing. Please report this issue.", { cause }));
1146
- }
1147
- const manifestBytes = Buffer.from(JSON.stringify(bundle.manifest));
1148
- const created = createdSchema.parse(await this.api("/api/shares", "POST", {
1149
- requestId,
1150
- manifest: {
1151
- size: manifestBytes.byteLength,
1152
- sha256: digestBytes(manifestBytes)
1153
- }
1154
- }));
1155
- const route = `/api/shares/${created.shareId}`;
1156
- await this.upload(created.upload, manifestBytes);
1157
- const { uploads } = registeredSchema.parse(await this.api(`${route}/manifest`, "POST", {}, void 0, MAX_UPLOAD_RESPONSE_BYTES));
1158
- try {
1159
- if (bundle.manifest.objects.some(({ id }) => !uploads[id])) throw new Error("The share service omitted an object upload URL.");
1160
- await beforeUpload?.();
1161
- } catch (error) {
1162
- await this.recoverLink(created.shareId).catch(async (failure) => {
1163
- if (failure instanceof ShareRequestError && failure.status === 409) await this.revoke(created.shareId);
1164
- }).catch(() => {});
1165
- throw new SharePreflightError(error);
1166
- }
1167
- for (let offset = 0; offset < bundle.manifest.objects.length; offset += UPLOAD_CONCURRENCY) {
1168
- const failed = (await Promise.allSettled(bundle.manifest.objects.slice(offset, offset + UPLOAD_CONCURRENCY).map(async (object) => {
1169
- await this.upload(uploads[object.id], bundle.objects.get(object.id));
1170
- }))).find((result) => result.status === "rejected");
1171
- if (failed) throw failed.reason;
1172
- }
1173
- for (let batch = 0; batch <= Math.ceil(bundle.manifest.objects.length / 32); batch++) {
1174
- const result = await this.api(`${route}/complete`, "POST", {});
1175
- if (linkSchema.safeParse(result).success) return linkSchema.parse(result);
1176
- z.strictObject({ complete: z.literal(false) }).parse(result);
1177
- }
1178
- throw new Error("Share verification did not finish.");
1179
- }
1180
- async list(cursor) {
1181
- const query = cursor ? `?cursor=${z.uuid().parse(cursor)}` : "";
1182
- return this.api(`/api/shares${query}`);
1183
- }
1184
- async recoverLink(shareId) {
1185
- return linkSchema.parse(await this.api(`/api/shares/${z.uuid().parse(shareId)}/link`));
1186
- }
1187
- async revoke(shareId) {
1188
- return this.api(`/api/shares/${z.uuid().parse(shareId)}`, "DELETE");
1189
- }
1190
- async download(shareId, capability) {
1191
- const route = `/api/shared/${z.uuid().parse(shareId)}`;
1192
- const received = receivedSchema.parse(await this.api(route, "GET", void 0, capability));
1193
- const manifest = received.manifest;
1194
- const objects = /* @__PURE__ */ new Map();
1195
- const download = async (object) => {
1196
- const signed = downloadSchema.parse(await this.api(`${route}/objects/${object.id}`, "GET", void 0, capability));
1197
- if (!signedUrlSchema.safeParse(signed.url).success) throw new Error("Invalid object download URL.");
1198
- const bytes = await readBoundedBytes(await this.send(signed.url, {
1199
- redirect: "error",
1200
- signal: AbortSignal.timeout(12e4)
1201
- }), object.size);
1202
- if (bytes.byteLength !== object.size || digestBytes(bytes) !== object.sha256) throw new Error("Downloaded share object failed its integrity check.");
1203
- objects.set(object.id, bytes);
1204
- };
1205
- for (let start = 0; start < manifest.objects.length; start += 4) await Promise.all(manifest.objects.slice(start, start + 4).map(download));
1206
- return {
1207
- manifest,
1208
- objects,
1209
- attribution: {
1210
- login: received.sender.login,
1211
- sharedAt: received.sharedAt
1212
- }
1326
+ attribution,
1327
+ validated: validateShareBundle(JSON.parse(await readFile(file, "utf8")))
1213
1328
  };
1214
1329
  }
1215
1330
  };
1216
1331
  //#endregion
1217
- export { sharedReviewId as a, verifyShareRepository as c, validateShareSources as d, StreamLimitError as f, shareIdSchema as h, SharedReviewStore as i, exportShare as l, parseShareLink as m, ShareClient as n, validateShareBundle as o, readBoundedStream as p, SharePreflightError as r, readShareRepository as s, ShareAuthError as t, referenceCommits as u };
1332
+ export { readBoundedStream as _, verifyShareRepository as a, exportShare as c, legacyModeSchema as d, lensReport as f, StreamLimitError as g, uncategorizedReport as h, readShareRepository as i, referenceCommits as l, reviewProgress as m, sharedReviewId as n, ShareAuthError as o, progressUpdateSchema as p, validateShareBundle as r, ShareClient as s, SharedReviewStore as t, validateShareSources as u, parseShareLink as v, shareIdSchema as y };