@dev.fast/whiteboard 0.0.0-stage → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { parseJsonText } from "@dev.fast/json";
|
|
16
|
+
import {
|
|
17
|
+
type JsonObject,
|
|
18
|
+
type JsonValue,
|
|
19
|
+
isJsonObject,
|
|
20
|
+
} from "@dev.fast/review-protocol";
|
|
21
|
+
import {
|
|
22
|
+
MAX_SHARE_MANIFEST_BYTES,
|
|
23
|
+
type ShareManifest,
|
|
24
|
+
shareIdSchema,
|
|
25
|
+
shareManifestSchema,
|
|
26
|
+
} from "@dev.fast/review-share-protocol";
|
|
27
|
+
import { textIncludesQuote } from "@review/evidence.js";
|
|
28
|
+
import { lensSchema } from "@review/review-api/diff-lenses.js";
|
|
29
|
+
import { ReviewInputError } from "@review/review-api/document.js";
|
|
30
|
+
import {
|
|
31
|
+
checkReferences,
|
|
32
|
+
documentSchema,
|
|
33
|
+
pinsSchema,
|
|
34
|
+
resourceReference,
|
|
35
|
+
resourceReferences,
|
|
36
|
+
sourceReferences,
|
|
37
|
+
} from "@review/review-api/document.js";
|
|
38
|
+
import type { LocalReviewData } from "@review/review-api/local-data.js";
|
|
39
|
+
import type { ReviewStore } from "@review/review-api/store.js";
|
|
40
|
+
import { traceSchema } from "@review/review-api/trace-schema.js";
|
|
41
|
+
import {
|
|
42
|
+
normalizedSoftwareElementSchema,
|
|
43
|
+
normalizedSoftwareRelationshipSchema,
|
|
44
|
+
} from "@review/software-map-model.js";
|
|
45
|
+
import {
|
|
46
|
+
liftFileLenses,
|
|
47
|
+
migrateStoredDocument,
|
|
48
|
+
migrateStoredLenses,
|
|
49
|
+
} from "@review/stored-document-migration.js";
|
|
50
|
+
import { z } from "zod";
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
type ShareBundle,
|
|
54
|
+
digestBytes,
|
|
55
|
+
validateShareSources,
|
|
56
|
+
} from "./export.js";
|
|
57
|
+
import {
|
|
58
|
+
fetchPinnedRepository,
|
|
59
|
+
repositoryReady,
|
|
60
|
+
sharedGit,
|
|
61
|
+
} from "./repository.js";
|
|
62
|
+
|
|
63
|
+
export const sharedSnapshotSchema = z.strictObject({
|
|
64
|
+
reviewId: z.string().min(1),
|
|
65
|
+
version: z.number().int().nonnegative(),
|
|
66
|
+
title: z.string().min(1),
|
|
67
|
+
pins: pinsSchema
|
|
68
|
+
.extend({
|
|
69
|
+
base: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
|
|
70
|
+
head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
|
|
71
|
+
})
|
|
72
|
+
.optional(),
|
|
73
|
+
document: documentSchema,
|
|
74
|
+
lenses: z.array(lensSchema).optional(),
|
|
75
|
+
createdAt: z.string(),
|
|
76
|
+
origin: z
|
|
77
|
+
.strictObject({
|
|
78
|
+
branch: z.string().optional(),
|
|
79
|
+
baseRef: z.string().optional(),
|
|
80
|
+
pullRequestNumber: z.number().int().positive().optional(),
|
|
81
|
+
pullRequestUrl: z.string().optional(),
|
|
82
|
+
revision: z.string().optional(),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const count = z.number().int().nonnegative();
|
|
88
|
+
|
|
89
|
+
const counts = z.strictObject({ additions: count, deletions: count });
|
|
90
|
+
|
|
91
|
+
const mapSchema = z.strictObject({
|
|
92
|
+
commit: z.string(),
|
|
93
|
+
side: z.enum(["base", "head"]),
|
|
94
|
+
elements: z.array(normalizedSoftwareElementSchema),
|
|
95
|
+
relationships: z.array(normalizedSoftwareRelationshipSchema),
|
|
96
|
+
countsByElementPath: z.record(z.string(), counts),
|
|
97
|
+
unmappedByElementPath: z.record(
|
|
98
|
+
z.string(),
|
|
99
|
+
counts.extend({
|
|
100
|
+
files: z.array(
|
|
101
|
+
counts.extend({
|
|
102
|
+
file: z.string(),
|
|
103
|
+
hunks: z.array(
|
|
104
|
+
z.strictObject({
|
|
105
|
+
startLine: count,
|
|
106
|
+
lines: z.array(
|
|
107
|
+
z.strictObject({
|
|
108
|
+
kind: z.enum(["add", "remove"]),
|
|
109
|
+
oldLine: count.nullable(),
|
|
110
|
+
newLine: count.nullable(),
|
|
111
|
+
text: z.string(),
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const sharePresentationSchema = z.strictObject({
|
|
123
|
+
maps: z.record(z.string(), mapSchema),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export function validateShareBundle(bundle: ShareBundle) {
|
|
127
|
+
const manifest = shareManifestSchema.parse(bundle.manifest);
|
|
128
|
+
|
|
129
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_SHARE_MANIFEST_BYTES)
|
|
130
|
+
throw new Error("Share manifest is too large.");
|
|
131
|
+
|
|
132
|
+
if (bundle.objects.size !== manifest.objects.length)
|
|
133
|
+
throw new Error("Share object count does not match its manifest.");
|
|
134
|
+
|
|
135
|
+
for (const object of manifest.objects) {
|
|
136
|
+
const bytes = bundle.objects.get(object.id);
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
!bytes ||
|
|
140
|
+
bytes.byteLength !== object.size ||
|
|
141
|
+
digestBytes(bytes) !== object.sha256
|
|
142
|
+
)
|
|
143
|
+
throw new Error(`Shared object ${object.id} is missing or corrupt.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const json = (id: string) =>
|
|
147
|
+
parseJsonText(Buffer.from(bundle.objects.get(id)!).toString());
|
|
148
|
+
|
|
149
|
+
const stored = json(manifest.snapshot);
|
|
150
|
+
|
|
151
|
+
// Bundles shared before a field was retired still open; the bytes stay
|
|
152
|
+
// sealed and only the parsed document drops the retired form. Lenses
|
|
153
|
+
// shared as document blocks read as the snapshot's lenses.
|
|
154
|
+
const snapshot = sharedSnapshotSchema.parse(
|
|
155
|
+
isJsonObject(stored) && "document" in stored
|
|
156
|
+
? liftSharedLenses(stored, migrateStoredDocument(stored.document))
|
|
157
|
+
: stored,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
snapshot.reviewId !== manifest.reviewId ||
|
|
162
|
+
snapshot.version !== manifest.version ||
|
|
163
|
+
snapshot.title !== manifest.title
|
|
164
|
+
)
|
|
165
|
+
throw new Error("Shared snapshot does not match its manifest.");
|
|
166
|
+
checkReferences(snapshot.document);
|
|
167
|
+
validateShareSources(snapshot);
|
|
168
|
+
|
|
169
|
+
if (Boolean(snapshot.pins) !== Boolean(manifest.repository))
|
|
170
|
+
throw new Error("Shared source pins require a repository.");
|
|
171
|
+
|
|
172
|
+
const presentation = sharePresentationSchema.parse(
|
|
173
|
+
json(manifest.presentation),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!snapshot.pins && Object.keys(presentation.maps).length)
|
|
177
|
+
throw new Error("Shared maps require source pins.");
|
|
178
|
+
|
|
179
|
+
const traces = new Map<
|
|
180
|
+
string,
|
|
181
|
+
Map<string, z.infer<typeof traceSchema>["events"][number]>
|
|
182
|
+
>();
|
|
183
|
+
|
|
184
|
+
for (const block of resourceReferences(snapshot.document)) {
|
|
185
|
+
const reference = resourceReference(block);
|
|
186
|
+
|
|
187
|
+
const resource = manifest.resources.find(
|
|
188
|
+
(item) => item.id === reference?.id,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!reference || !resource || resource.kind !== reference.kind)
|
|
192
|
+
throw new Error("Missing or mismatched shared resource.");
|
|
193
|
+
|
|
194
|
+
if (block.type === "trace_quote") {
|
|
195
|
+
let events = traces.get(resource.object);
|
|
196
|
+
|
|
197
|
+
if (!events) {
|
|
198
|
+
const trace = traceSchema.parse(json(resource.object));
|
|
199
|
+
events = new Map(trace.events.map((event) => [event.id, event]));
|
|
200
|
+
|
|
201
|
+
if (events.size !== trace.events.length)
|
|
202
|
+
throw new Error("Duplicate shared trace event.");
|
|
203
|
+
traces.set(resource.object, events);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const event = events.get(block.eventId);
|
|
207
|
+
|
|
208
|
+
if (!event || !textIncludesQuote(event.text, block.text))
|
|
209
|
+
throw new Error("Shared quote does not match its trace.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (block.type === "software_map") {
|
|
213
|
+
const map = presentation.maps[block.mapVersionId];
|
|
214
|
+
|
|
215
|
+
if (!map || map.commit !== snapshot.pins?.[map.side])
|
|
216
|
+
throw new Error("Shared map pins do not match.");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { manifest, snapshot, presentation };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function liftSharedLenses(stored: JsonObject, migrated: JsonValue): JsonObject {
|
|
224
|
+
const { document, lenses } = liftFileLenses(migrated);
|
|
225
|
+
|
|
226
|
+
// SAFETY: migration returns the array it was given, rewritten.
|
|
227
|
+
const kept = Array.isArray(stored.lenses)
|
|
228
|
+
? (migrateStoredLenses(stored.lenses) as JsonValue[])
|
|
229
|
+
: [];
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...stored,
|
|
233
|
+
document,
|
|
234
|
+
...((kept.length || lenses.length) && { lenses: [...kept, ...lenses] }),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function sharedReviewId(origin: string, shareId: string) {
|
|
239
|
+
const url = new URL(origin);
|
|
240
|
+
shareIdSchema.parse(shareId);
|
|
241
|
+
|
|
242
|
+
return `shared-${digestBytes(Buffer.from(JSON.stringify([url.origin, shareId])))}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Separate immutable directory tree; authoring mutation handlers cannot write it. */
|
|
246
|
+
export class SharedReviewStore {
|
|
247
|
+
private readonly loaded = new Map<string, Omit<ShareBundle, "objects">>();
|
|
248
|
+
private readonly validated = new Map<
|
|
249
|
+
string,
|
|
250
|
+
ReturnType<typeof validateShareBundle>
|
|
251
|
+
>();
|
|
252
|
+
private readonly attention = new Map<
|
|
253
|
+
string,
|
|
254
|
+
{ viewedAt: string | null; dismissedAt: string | null }
|
|
255
|
+
>();
|
|
256
|
+
private readonly listeners = new Set<() => void>();
|
|
257
|
+
private catalogLoaded = false;
|
|
258
|
+
private readonly downloads = new Set<Promise<void>>();
|
|
259
|
+
|
|
260
|
+
trackImport(job: Promise<void>) {
|
|
261
|
+
this.downloads.add(job);
|
|
262
|
+
void job.finally(() => this.downloads.delete(job));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async close() {
|
|
266
|
+
await Promise.allSettled([...this.downloads, ...this.jobs.values()]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private local?: { store: ReviewStore; data: LocalReviewData };
|
|
270
|
+
private readonly repositories = new Map<string, string>();
|
|
271
|
+
private readonly jobs = new Map<string, Promise<void>>();
|
|
272
|
+
private readonly states = new Map<
|
|
273
|
+
string,
|
|
274
|
+
{
|
|
275
|
+
stage: "downloading" | "fetching" | "validating" | "ready" | "error";
|
|
276
|
+
error?: string;
|
|
277
|
+
}
|
|
278
|
+
>();
|
|
279
|
+
|
|
280
|
+
constructor(
|
|
281
|
+
readonly root: string,
|
|
282
|
+
private readonly fetchRepository = fetchPinnedRepository,
|
|
283
|
+
) {}
|
|
284
|
+
|
|
285
|
+
connect(store: ReviewStore, data: LocalReviewData) {
|
|
286
|
+
this.local = { store, data };
|
|
287
|
+
data.workspaces.attachExternalReviews({
|
|
288
|
+
has: (id) =>
|
|
289
|
+
this.has(id) || (!this.catalogLoaded && id.startsWith("shared-")),
|
|
290
|
+
subscribe: (listener) => this.subscribe(listener),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
has(id: string) {
|
|
295
|
+
return this.loaded.has(id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
status(id: string) {
|
|
299
|
+
return (
|
|
300
|
+
this.states.get(id) ?? {
|
|
301
|
+
stage: "error" as const,
|
|
302
|
+
error: "Shared review is not available.",
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
setStatus(
|
|
308
|
+
id: string,
|
|
309
|
+
stage: "downloading" | "fetching" | "validating" | "ready" | "error",
|
|
310
|
+
error?: string,
|
|
311
|
+
) {
|
|
312
|
+
this.states.set(id, { stage, error });
|
|
313
|
+
|
|
314
|
+
for (const listener of this.listeners) listener();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
repositoryRoot(id: string) {
|
|
318
|
+
if (!/^shared-[a-f0-9]{64}$/.test(id))
|
|
319
|
+
throw new ReviewInputError("Invalid shared review ID.");
|
|
320
|
+
|
|
321
|
+
return path.join(this.root, ".repositories", id);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async prepare(id: string): Promise<void> {
|
|
325
|
+
const active = this.jobs.get(id);
|
|
326
|
+
|
|
327
|
+
if (active) return active;
|
|
328
|
+
const job = this.prepareRepository(id).finally(() => this.jobs.delete(id));
|
|
329
|
+
this.jobs.set(id, job);
|
|
330
|
+
|
|
331
|
+
return job;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async prepareRepository(id: string) {
|
|
335
|
+
const local = this.local;
|
|
336
|
+
const saved = this.validated.get(id);
|
|
337
|
+
|
|
338
|
+
if (!saved)
|
|
339
|
+
throw new ReviewInputError("Shared review is not available.", 404);
|
|
340
|
+
|
|
341
|
+
if (!saved.snapshot.pins) {
|
|
342
|
+
this.setStatus(id, "ready");
|
|
343
|
+
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!local)
|
|
348
|
+
throw new ReviewInputError("Repository service is unavailable.", 409);
|
|
349
|
+
const root = this.repositoryRoot(id);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
this.setStatus(id, "fetching");
|
|
353
|
+
|
|
354
|
+
if (!(await repositoryReady(root, saved.snapshot.pins))) {
|
|
355
|
+
const prior = this.repositories.get(id);
|
|
356
|
+
await local.data.workspaces.remove(id);
|
|
357
|
+
|
|
358
|
+
if (prior) await local.data.forgetRepository(prior);
|
|
359
|
+
await rm(root, { recursive: true, force: true });
|
|
360
|
+
await this.fetchRepository(
|
|
361
|
+
root,
|
|
362
|
+
saved.manifest.repository!.cloneUrl,
|
|
363
|
+
saved.snapshot.pins,
|
|
364
|
+
);
|
|
365
|
+
await sharedGit(root, [
|
|
366
|
+
"checkout",
|
|
367
|
+
"--detach",
|
|
368
|
+
saved.snapshot.pins.head,
|
|
369
|
+
]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const repository = await local.data.register(root);
|
|
373
|
+
this.repositories.set(id, repository.id);
|
|
374
|
+
await writeFile(
|
|
375
|
+
path.join(this.root, id, "repository.json"),
|
|
376
|
+
JSON.stringify({ repositoryId: repository.id, ready: false }),
|
|
377
|
+
{ mode: 0o600 },
|
|
378
|
+
);
|
|
379
|
+
const pins = { ...saved.snapshot.pins, repositoryId: repository.id };
|
|
380
|
+
this.setStatus(id, "validating");
|
|
381
|
+
await local.data.validatePins(pins);
|
|
382
|
+
|
|
383
|
+
const references = sourceReferences(saved.snapshot.document).map(
|
|
384
|
+
(item) => item.source,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
for (const map of Object.values(saved.presentation.maps))
|
|
388
|
+
for (const element of map.elements)
|
|
389
|
+
for (const range of element.sourceRanges ?? [])
|
|
390
|
+
references.push({ ...range, side: map.side });
|
|
391
|
+
|
|
392
|
+
await local.data.validateSources(pins, references);
|
|
393
|
+
await local.data.workspaces.open(id, pins);
|
|
394
|
+
|
|
395
|
+
if (
|
|
396
|
+
local.data.workspaces.list(id).some((workspace) => !workspace.rootPath)
|
|
397
|
+
)
|
|
398
|
+
throw new ReviewInputError(
|
|
399
|
+
"Could not prepare the pinned checkout. Retry opening the share.",
|
|
400
|
+
409,
|
|
401
|
+
);
|
|
402
|
+
await writeFile(
|
|
403
|
+
path.join(this.root, id, "repository.json"),
|
|
404
|
+
JSON.stringify({ repositoryId: repository.id, ready: true }),
|
|
405
|
+
{ mode: 0o600 },
|
|
406
|
+
);
|
|
407
|
+
this.setStatus(id, "ready");
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const message =
|
|
410
|
+
error instanceof ReviewInputError
|
|
411
|
+
? error.message
|
|
412
|
+
: "Could not prepare the shared repository. Check Git credentials and retry.";
|
|
413
|
+
|
|
414
|
+
this.setStatus(id, "error", message);
|
|
415
|
+
throw new ReviewInputError(message, 409);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async assertReady(id: string) {
|
|
420
|
+
const saved = this.validated.get(id);
|
|
421
|
+
|
|
422
|
+
if (
|
|
423
|
+
!saved ||
|
|
424
|
+
(saved.snapshot.pins &&
|
|
425
|
+
!(await repositoryReady(this.repositoryRoot(id), saved.snapshot.pins)))
|
|
426
|
+
) {
|
|
427
|
+
this.setStatus(
|
|
428
|
+
id,
|
|
429
|
+
"error",
|
|
430
|
+
"The managed checkout is missing. Reopen the share link to fetch it again.",
|
|
431
|
+
);
|
|
432
|
+
throw new ReviewInputError(this.status(id).error!, 409);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.get(id);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
subscribe(listener: () => void) {
|
|
439
|
+
this.listeners.add(listener);
|
|
440
|
+
|
|
441
|
+
return () => {
|
|
442
|
+
this.listeners.delete(listener);
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async load() {
|
|
447
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
448
|
+
|
|
449
|
+
for (const id of await readdir(this.root)) {
|
|
450
|
+
if (!/^shared-[a-f0-9]{64}$/.test(id)) continue;
|
|
451
|
+
let saved: Awaited<ReturnType<SharedReviewStore["readValidated"]>>;
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
saved = await this.readValidated(id);
|
|
455
|
+
} catch {
|
|
456
|
+
await rename(
|
|
457
|
+
path.join(this.root, id),
|
|
458
|
+
path.join(this.root, `.invalid-${id}-${Date.now()}`),
|
|
459
|
+
);
|
|
460
|
+
console.warn(
|
|
461
|
+
"A cached shared review was corrupt. Reopen its share link to download it again.",
|
|
462
|
+
);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const { bundle, validated } = saved;
|
|
467
|
+
this.loaded.set(id, {
|
|
468
|
+
manifest: bundle.manifest,
|
|
469
|
+
attribution: bundle.attribution,
|
|
470
|
+
});
|
|
471
|
+
this.validated.set(id, validated);
|
|
472
|
+
|
|
473
|
+
let prepared = false;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const metadata = z
|
|
477
|
+
.strictObject({ repositoryId: z.string(), ready: z.boolean() })
|
|
478
|
+
.parse(
|
|
479
|
+
JSON.parse(
|
|
480
|
+
await readFile(
|
|
481
|
+
path.join(this.root, id, "repository.json"),
|
|
482
|
+
"utf8",
|
|
483
|
+
),
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
this.local?.store.repositoryPath(metadata.repositoryId) ===
|
|
489
|
+
path.join(await realpath(this.root), ".repositories", id)
|
|
490
|
+
) {
|
|
491
|
+
this.repositories.set(id, metadata.repositoryId);
|
|
492
|
+
prepared = metadata.ready;
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
/* A first or interrupted import has no local registration yet. */
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!validated.snapshot.pins) this.setStatus(id, "ready");
|
|
499
|
+
else if (
|
|
500
|
+
prepared &&
|
|
501
|
+
this.local &&
|
|
502
|
+
(await repositoryReady(
|
|
503
|
+
this.repositoryRoot(id),
|
|
504
|
+
validated.snapshot.pins,
|
|
505
|
+
))
|
|
506
|
+
) {
|
|
507
|
+
const repository = await this.local.data.register(
|
|
508
|
+
this.repositoryRoot(id),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
this.repositories.set(id, repository.id);
|
|
512
|
+
this.setStatus(id, "ready");
|
|
513
|
+
} else
|
|
514
|
+
this.setStatus(
|
|
515
|
+
id,
|
|
516
|
+
"error",
|
|
517
|
+
"Reopen the share link to fetch its repository.",
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
this.attention.set(
|
|
522
|
+
id,
|
|
523
|
+
z
|
|
524
|
+
.strictObject({
|
|
525
|
+
viewedAt: z.string().nullable(),
|
|
526
|
+
dismissedAt: z.string().nullable(),
|
|
527
|
+
})
|
|
528
|
+
.parse(
|
|
529
|
+
JSON.parse(
|
|
530
|
+
await readFile(
|
|
531
|
+
path.join(this.root, id, "attention.json"),
|
|
532
|
+
"utf8",
|
|
533
|
+
),
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
} catch {
|
|
538
|
+
/* Attention is optional local metadata. */
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
this.catalogLoaded = true;
|
|
543
|
+
|
|
544
|
+
for (const listener of this.listeners) listener();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
get(id: string) {
|
|
548
|
+
const bundle = this.loaded.get(id);
|
|
549
|
+
|
|
550
|
+
if (!bundle)
|
|
551
|
+
throw new ReviewInputError("Shared review is not available.", 404);
|
|
552
|
+
const validated = this.validated.get(id)!;
|
|
553
|
+
const repositoryId = this.repositories.get(id);
|
|
554
|
+
|
|
555
|
+
if (
|
|
556
|
+
this.status(id).stage !== "ready" ||
|
|
557
|
+
(validated.snapshot.pins &&
|
|
558
|
+
(!repositoryId ||
|
|
559
|
+
!existsSync(path.join(this.repositoryRoot(id), ".git"))))
|
|
560
|
+
)
|
|
561
|
+
throw new ReviewInputError(
|
|
562
|
+
this.status(id).error ??
|
|
563
|
+
"Fetch the shared repository before opening this review.",
|
|
564
|
+
409,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
...validated,
|
|
569
|
+
snapshot: {
|
|
570
|
+
...validated.snapshot,
|
|
571
|
+
reviewId: id,
|
|
572
|
+
pins: validated.snapshot.pins
|
|
573
|
+
? { ...validated.snapshot.pins, repositoryId: repositoryId! }
|
|
574
|
+
: undefined,
|
|
575
|
+
target: validated.snapshot.pins
|
|
576
|
+
? {
|
|
577
|
+
kind: "commits" as const,
|
|
578
|
+
...validated.snapshot.pins,
|
|
579
|
+
repositoryId: repositoryId!,
|
|
580
|
+
}
|
|
581
|
+
: undefined,
|
|
582
|
+
shared: {
|
|
583
|
+
...bundle.attribution,
|
|
584
|
+
cloneUrl: bundle.manifest.repository?.cloneUrl,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
list(mode: "structural" | "textual" = "structural") {
|
|
591
|
+
const reviews = this.loaded
|
|
592
|
+
.keys()
|
|
593
|
+
.filter(
|
|
594
|
+
(id) =>
|
|
595
|
+
this.status(id).stage === "ready" &&
|
|
596
|
+
(!this.validated.get(id)?.snapshot.pins ||
|
|
597
|
+
existsSync(path.join(this.repositoryRoot(id), ".git"))),
|
|
598
|
+
)
|
|
599
|
+
.map((id) => {
|
|
600
|
+
const { document: _document, ...snapshot } = this.get(id).snapshot;
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
...snapshot,
|
|
604
|
+
firstCreatedAt:
|
|
605
|
+
snapshot.version === 0 ? snapshot.createdAt : undefined,
|
|
606
|
+
repositoryName: "Shared review",
|
|
607
|
+
viewedAt: this.attention.get(id)?.viewedAt ?? null,
|
|
608
|
+
dismissedAt: this.attention.get(id)?.dismissedAt ?? null,
|
|
609
|
+
};
|
|
610
|
+
})
|
|
611
|
+
.toArray();
|
|
612
|
+
|
|
613
|
+
return this.local?.store.withDiffStats(reviews, mode) ?? reviews;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async readObject(id: string, objectId: string) {
|
|
617
|
+
const entry = this.get(id).manifest.objects.find(
|
|
618
|
+
(object) => object.id === objectId,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (!entry)
|
|
622
|
+
throw new ReviewInputError("Shared object is unavailable.", 404);
|
|
623
|
+
const file = path.join(this.root, id, objectId);
|
|
624
|
+
|
|
625
|
+
if ((await stat(file)).size !== entry.size)
|
|
626
|
+
throw new Error("Corrupt cached shared object.");
|
|
627
|
+
const bytes = await readFile(file);
|
|
628
|
+
|
|
629
|
+
if (digestBytes(bytes) !== entry.sha256)
|
|
630
|
+
throw new Error("Corrupt cached shared object.");
|
|
631
|
+
|
|
632
|
+
return bytes;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async removeLocal(id: string) {
|
|
636
|
+
await this.jobs.get(id)?.catch(() => {});
|
|
637
|
+
|
|
638
|
+
if (!this.loaded.has(id))
|
|
639
|
+
throw new ReviewInputError("Shared review is not available.", 404);
|
|
640
|
+
await this.local?.data.workspaces.remove(id);
|
|
641
|
+
const repositoryId = this.repositories.get(id);
|
|
642
|
+
|
|
643
|
+
if (repositoryId) {
|
|
644
|
+
await this.local?.data.forgetRepository(repositoryId);
|
|
645
|
+
this.local?.store.unregisterRepository(repositoryId);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
await rm(this.repositoryRoot(id), { recursive: true, force: true });
|
|
649
|
+
this.repositories.delete(id);
|
|
650
|
+
this.states.delete(id);
|
|
651
|
+
await rm(path.join(this.root, id), { recursive: true, force: true });
|
|
652
|
+
this.loaded.delete(id);
|
|
653
|
+
this.validated.delete(id);
|
|
654
|
+
this.attention.delete(id);
|
|
655
|
+
|
|
656
|
+
for (const listener of this.listeners) listener();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async setAttention(id: string, action: "view" | "dismiss" | "restore") {
|
|
660
|
+
this.get(id);
|
|
661
|
+
|
|
662
|
+
const state = this.attention.get(id) ?? {
|
|
663
|
+
viewedAt: null,
|
|
664
|
+
dismissedAt: null,
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
if (action === "view") state.viewedAt = new Date().toISOString();
|
|
668
|
+
else
|
|
669
|
+
state.dismissedAt =
|
|
670
|
+
action === "dismiss" ? new Date().toISOString() : null;
|
|
671
|
+
await writeFile(
|
|
672
|
+
path.join(this.root, id, "attention.json"),
|
|
673
|
+
JSON.stringify(state),
|
|
674
|
+
{ mode: 0o600 },
|
|
675
|
+
);
|
|
676
|
+
this.attention.set(id, state);
|
|
677
|
+
|
|
678
|
+
for (const listener of this.listeners) listener();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async import(origin: string, shareId: string, bundle: ShareBundle) {
|
|
682
|
+
const validated = validateShareBundle(bundle);
|
|
683
|
+
const id = sharedReviewId(origin, shareId);
|
|
684
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
685
|
+
const staging = await mkdtemp(path.join(this.root, ".import-"));
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
await writeFile(
|
|
689
|
+
path.join(staging, "manifest.json"),
|
|
690
|
+
JSON.stringify(bundle.manifest),
|
|
691
|
+
{ mode: 0o600 },
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
if (bundle.attribution)
|
|
695
|
+
await writeFile(
|
|
696
|
+
path.join(staging, "attribution.json"),
|
|
697
|
+
JSON.stringify(bundle.attribution),
|
|
698
|
+
{ mode: 0o600 },
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
for (const [objectId, bytes] of bundle.objects)
|
|
702
|
+
await writeFile(path.join(staging, objectId), bytes, { mode: 0o600 });
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
await rename(staging, path.join(this.root, id));
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (
|
|
708
|
+
!(
|
|
709
|
+
error instanceof Error &&
|
|
710
|
+
"code" in error &&
|
|
711
|
+
["ENOTEMPTY", "EEXIST"].includes(String(error.code))
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
throw error;
|
|
715
|
+
const existing = await this.read(id);
|
|
716
|
+
|
|
717
|
+
if (
|
|
718
|
+
JSON.stringify(existing.manifest) !== JSON.stringify(bundle.manifest)
|
|
719
|
+
)
|
|
720
|
+
throw new Error("An immutable share changed its manifest.");
|
|
721
|
+
}
|
|
722
|
+
} finally {
|
|
723
|
+
await rm(staging, { recursive: true, force: true });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
this.loaded.set(id, {
|
|
727
|
+
manifest: bundle.manifest,
|
|
728
|
+
attribution: bundle.attribution,
|
|
729
|
+
});
|
|
730
|
+
this.validated.set(id, validated);
|
|
731
|
+
|
|
732
|
+
for (const listener of this.listeners) listener();
|
|
733
|
+
|
|
734
|
+
await this.prepare(id);
|
|
735
|
+
|
|
736
|
+
return id;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async read(id: string): Promise<ShareBundle> {
|
|
740
|
+
return (await this.readValidated(id)).bundle;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async readValidated(id: string) {
|
|
744
|
+
if (!/^shared-[a-f0-9]{64}$/.test(id))
|
|
745
|
+
throw new Error("Invalid shared review ID.");
|
|
746
|
+
const dir = path.join(this.root, id);
|
|
747
|
+
|
|
748
|
+
if (
|
|
749
|
+
(await stat(path.join(dir, "manifest.json"))).size >
|
|
750
|
+
MAX_SHARE_MANIFEST_BYTES
|
|
751
|
+
)
|
|
752
|
+
throw new Error("Shared manifest is too large.");
|
|
753
|
+
|
|
754
|
+
const manifest: ShareManifest = shareManifestSchema.parse(
|
|
755
|
+
JSON.parse(await readFile(path.join(dir, "manifest.json"), "utf8")),
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
const objects = new Map<string, Uint8Array>();
|
|
759
|
+
|
|
760
|
+
for (const object of manifest.objects) {
|
|
761
|
+
const file = path.join(dir, object.id);
|
|
762
|
+
|
|
763
|
+
if ((await stat(file)).size !== object.size)
|
|
764
|
+
throw new Error("Corrupt cached shared object.");
|
|
765
|
+
objects.set(object.id, await readFile(file));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let attribution: ShareBundle["attribution"];
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
attribution = z
|
|
772
|
+
.strictObject({ login: z.string(), sharedAt: z.number() })
|
|
773
|
+
.parse(
|
|
774
|
+
JSON.parse(
|
|
775
|
+
await readFile(path.join(dir, "attribution.json"), "utf8"),
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
} catch {
|
|
779
|
+
/* Local-only fixtures have no hosted sender. */
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const bundle = { manifest, objects, attribution };
|
|
783
|
+
const validated = validateShareBundle(bundle);
|
|
784
|
+
|
|
785
|
+
return { bundle, validated };
|
|
786
|
+
}
|
|
787
|
+
}
|