@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98

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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,153 @@
1
+ import type { StructuralDiffEvent } from "@dev.fast/review-protocol";
2
+
3
+ import {
4
+ type StructuralDiffRequest,
5
+ structuralDiff,
6
+ } from "./structural-diff.js";
7
+
8
+ const IDLE_COMPARISONS = 10;
9
+
10
+ /** Replays a pinned comparison to concurrent rendering and coverage consumers. */
11
+ export class StructuralComparisons {
12
+ private readonly entries = new Map<string, Comparison>();
13
+
14
+ async *stream(
15
+ input: StructuralDiffRequest,
16
+ ): AsyncGenerator<StructuralDiffEvent> {
17
+ input.signal.throwIfAborted();
18
+
19
+ const key = JSON.stringify([
20
+ configGeneration,
21
+ input.repositoryPath,
22
+ input.comparison,
23
+ input.paths,
24
+ ]);
25
+
26
+ let entry = this.entries.get(key);
27
+
28
+ if (entry) this.entries.delete(key);
29
+ else entry = new Comparison(input);
30
+ this.entries.set(key, entry);
31
+
32
+ entry.readers++;
33
+
34
+ try {
35
+ yield* entry.read(input.signal);
36
+ } finally {
37
+ entry.readers--;
38
+
39
+ if (
40
+ (!entry.done && !entry.initialReady && !entry.readers) ||
41
+ entry.error ||
42
+ entry.failed
43
+ ) {
44
+ entry.abort.abort();
45
+ this.entries.delete(key);
46
+ }
47
+ // Keep the most recently read idle comparisons, including background enrichment.
48
+ // Coverage can finish before an editor subscribes; it must not cancel summaries.
49
+
50
+ const idle = [...this.entries].filter(
51
+ ([, value]) => (value.done || value.initialReady) && !value.readers,
52
+ );
53
+
54
+ for (const [oldKey, old] of idle.slice(0, -IDLE_COMPARISONS)) {
55
+ old.abort.abort();
56
+ this.entries.delete(oldKey);
57
+ }
58
+ }
59
+ }
60
+
61
+ close(): void {
62
+ for (const entry of this.entries.values()) entry.abort.abort();
63
+ this.entries.clear();
64
+ }
65
+ }
66
+
67
+ let configGeneration = 0;
68
+
69
+ export function invalidateStructuralComparisons(): void {
70
+ configGeneration++;
71
+ }
72
+
73
+ class Comparison {
74
+ readonly abort = new AbortController();
75
+ readonly events: StructuralDiffEvent[] = [];
76
+ readers = 0;
77
+ done = false;
78
+ error: unknown;
79
+ failed = false;
80
+ initialReady = false;
81
+ private remaining: Set<string> | undefined;
82
+ private readonly listeners = new Set<() => void>();
83
+
84
+ constructor(input: StructuralDiffRequest) {
85
+ void this.run(input);
86
+ }
87
+
88
+ private async run(input: StructuralDiffRequest): Promise<void> {
89
+ try {
90
+ for await (const event of structuralDiff({
91
+ ...input,
92
+ signal: this.abort.signal,
93
+ })) {
94
+ if (event.type === "start") {
95
+ this.remaining = new Set(
96
+ event.files.map(
97
+ (entry) => (entry.file.rhs ?? entry.file.lhs)!.path,
98
+ ),
99
+ );
100
+ this.initialReady = this.remaining.size === 0;
101
+ } else if (event.type === "file") {
102
+ const path = (event.file.rhs ?? event.file.lhs)!.path;
103
+
104
+ if (!this.remaining?.delete(path))
105
+ throw new Error(`Unexpected structural result: ${path}`);
106
+ this.initialReady = this.remaining.size === 0;
107
+ }
108
+
109
+ if (event.type === "complete")
110
+ this.failed = event.failed > 0 || !!event.aborted;
111
+ this.events.push(event);
112
+
113
+ for (const wake of this.listeners) wake();
114
+ }
115
+ } catch (error) {
116
+ this.error = error;
117
+ } finally {
118
+ this.done = true;
119
+
120
+ for (const wake of this.listeners) wake();
121
+ }
122
+ }
123
+
124
+ async *read(signal: AbortSignal): AsyncGenerator<StructuralDiffEvent> {
125
+ let index = 0;
126
+
127
+ while (true) {
128
+ signal.throwIfAborted();
129
+
130
+ if (index < this.events.length) {
131
+ yield this.events[index++];
132
+ continue;
133
+ }
134
+
135
+ if (this.done) {
136
+ if (this.error) throw this.error;
137
+
138
+ return;
139
+ }
140
+
141
+ await new Promise<void>((resolve) => {
142
+ const wake = () => {
143
+ this.listeners.delete(wake);
144
+ signal.removeEventListener("abort", wake);
145
+ resolve();
146
+ };
147
+
148
+ this.listeners.add(wake);
149
+ signal.addEventListener("abort", wake, { once: true });
150
+ });
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,186 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline";
5
+
6
+ import {
7
+ STRUCTURAL_DIFF_WIRE_VERSION,
8
+ type StructuralDiffEvent,
9
+ type StructuralProblem,
10
+ decodeStructuralDiffEvent,
11
+ } from "@dev.fast/review-protocol";
12
+ import { findReviewPackageRoot } from "@review/package-paths";
13
+
14
+ import { installedFullDiffr } from "./diffr-languages.js";
15
+
16
+ export type DiffComparison =
17
+ | { kind: "trees"; base: string; head: string }
18
+ | { kind: "merge-base"; base: string; head: string }
19
+ // The revision separates cached streams after a working-file save.
20
+ | { kind: "worktree"; base: string; revision: string };
21
+
22
+ export interface StructuralDiffRequest {
23
+ repositoryPath: string;
24
+ comparison: DiffComparison;
25
+ paths?: readonly string[];
26
+ signal: AbortSignal;
27
+ }
28
+
29
+ export function diffrExecutable(
30
+ packageRoot = findReviewPackageRoot(import.meta.url),
31
+ ): string {
32
+ if (process.env.REVIEW_DIFFR_BINARY) return process.env.REVIEW_DIFFR_BINARY;
33
+
34
+ const full = installedFullDiffr(packageRoot);
35
+
36
+ if (full) return full;
37
+
38
+ const bundled = path.join(
39
+ packageRoot,
40
+ "bin",
41
+ process.platform === "win32" ? "diffr.exe" : "diffr",
42
+ );
43
+
44
+ return existsSync(bundled) ? bundled : "diffr";
45
+ }
46
+
47
+ export function diffrMissingError(): Error {
48
+ return new Error(
49
+ `Cannot find diffr at ${diffrExecutable()}. Whiteboard Desktop bundles it at bin/diffr under its runtime; in a checkout, run \`pnpm --filter @dev.fast/whiteboard ensure:diffr\` or install diffr on PATH, or set REVIEW_DIFFR_BINARY to its executable.`,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Stream validated diffr records. File errors and aborted completion are data;
55
+ * launch failures, malformed/truncated streams and unexpected exits throw.
56
+ * Closing the iterator (including a consumer's break) terminates the child.
57
+ */
58
+ export async function* structuralDiff(
59
+ input: StructuralDiffRequest,
60
+ ): AsyncGenerator<StructuralDiffEvent> {
61
+ input.signal.throwIfAborted();
62
+ const comparison = input.comparison;
63
+
64
+ const args = [
65
+ "--repo",
66
+ input.repositoryPath,
67
+ "--format",
68
+ "ndjson",
69
+ "--stream-annotations",
70
+ ];
71
+
72
+ switch (comparison.kind) {
73
+ case "worktree":
74
+ args.push(comparison.base);
75
+ break;
76
+ case "trees":
77
+ args.push(comparison.base, comparison.head);
78
+ break;
79
+ case "merge-base":
80
+ args.push(`${comparison.base}...${comparison.head}`);
81
+ }
82
+
83
+ args.push("--", ...(input.paths ?? []));
84
+
85
+ const idleAbort = new AbortController();
86
+ const idle = setTimeout(() => idleAbort.abort(), 120_000);
87
+ const signal = AbortSignal.any([idleAbort.signal, input.signal]);
88
+
89
+ // The host inherits its own environment and runs from the repository, so
90
+ // diffr reads the user's config and keys exactly as it would from a shell.
91
+ console.info(
92
+ `[Review] structural diff: ${diffrExecutable()} ${args.join(" ")}`,
93
+ );
94
+
95
+ const child = spawn(diffrExecutable(), args, {
96
+ cwd: input.repositoryPath,
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ signal,
99
+ });
100
+
101
+ let stderr = "";
102
+ child.stderr.setEncoding("utf8");
103
+ child.stderr.on("data", (chunk: string) => {
104
+ stderr = (stderr + chunk).slice(-16_384);
105
+ });
106
+
107
+ const exited = new Promise<number | null>((resolve, reject) => {
108
+ child.once("error", (error: NodeJS.ErrnoException) => {
109
+ reject(error.code === "ENOENT" ? diffrMissingError() : error);
110
+ });
111
+ child.once("close", (code) => resolve(code));
112
+ });
113
+
114
+ const exitError = (code: number | null) =>
115
+ new Error(`diffr exited with ${code}: ${stderr}`);
116
+
117
+ // Observe process errors immediately, including before stdout closes.
118
+ void exited.catch(() => {});
119
+ let started = false;
120
+ let completed = false;
121
+ let aborted: StructuralProblem | undefined;
122
+ let failed = 0;
123
+ let annotationFailed = false;
124
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
125
+
126
+ try {
127
+ for await (const line of lines) {
128
+ // Large comparisons may take minutes while continuing to make progress.
129
+ idle.refresh();
130
+
131
+ // Bound individual records, not the entire streamed comparison: a
132
+ // directory move can legitimately contain thousands of small files.
133
+ if (Buffer.byteLength(line) > 64 * 1024 * 1024)
134
+ throw new Error("Structural diff record exceeded 64 MiB.");
135
+
136
+ if (!line.trim()) continue;
137
+ const event = decodeStructuralDiffEvent(line);
138
+
139
+ if (completed) throw new Error("diffr emitted data after completion.");
140
+
141
+ if (!started) {
142
+ if (
143
+ event.type !== "start" ||
144
+ event.version !== STRUCTURAL_DIFF_WIRE_VERSION
145
+ ) {
146
+ throw new Error("Unsupported diffr stream protocol.");
147
+ }
148
+
149
+ started = true;
150
+ } else if (event.type === "complete") {
151
+ completed = true;
152
+
153
+ failed = event.failed;
154
+ aborted = event.aborted;
155
+ } else if (event.type === "annotations") {
156
+ annotationFailed ||= event.error !== undefined;
157
+ } else if (event.type !== "file") {
158
+ throw new Error(`Unexpected diffr event: ${event.type}`);
159
+ }
160
+
161
+ yield event;
162
+ }
163
+
164
+ if (!completed) {
165
+ const code = await exited;
166
+
167
+ if (code !== 0) throw exitError(code);
168
+ throw new Error("diffr stream ended before completion.");
169
+ }
170
+
171
+ // diffr exits 2 for file or enrichment failures already reported by the stream.
172
+ const code = await exited;
173
+
174
+ if (
175
+ code !== 0 &&
176
+ !(code === 2 && (failed > 0 || annotationFailed || aborted !== undefined))
177
+ )
178
+ throw exitError(code);
179
+ } finally {
180
+ clearTimeout(idle);
181
+ lines.close();
182
+
183
+ if (child.exitCode === null) child.kill();
184
+ await exited.catch(() => {});
185
+ }
186
+ }
@@ -0,0 +1,241 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { cp, mkdir, readFile, rename, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { resolveRevision } from "@dev.fast/local-vcs";
6
+ import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
7
+ import {
8
+ documentSchema,
9
+ resourceReferences,
10
+ } from "@review/review-api/document";
11
+ import type { LocalReviewData } from "@review/review-api/local-data";
12
+ import type { ReviewStore, Snapshot } from "@review/review-api/store";
13
+ import { devReviewHome } from "@review/review-home-paths";
14
+ import { z } from "zod";
15
+
16
+ const authoredSchema = z.strictObject({
17
+ title: z.string().min(1),
18
+ document: documentSchema,
19
+ });
20
+
21
+ const pinsSchema = z.strictObject({
22
+ base: z.string().regex(/^[a-f0-9]{40}$/),
23
+ head: z.string().regex(/^[a-f0-9]{40}$/),
24
+ });
25
+
26
+ const stampSchema = z.object({
27
+ version: z.literal(10),
28
+ reviewUuid: z.string(),
29
+ contentHash: z.string(),
30
+ });
31
+
32
+ export async function readTutorialAssets(assetsRoot: string) {
33
+ const names = [
34
+ "document.json",
35
+ "trace.json",
36
+ "software-map.json",
37
+ "pins.json",
38
+ ];
39
+
40
+ const bytes = await Promise.all(
41
+ names.map((name) => readFile(path.join(assetsRoot, name), "utf8")),
42
+ );
43
+
44
+ return {
45
+ authored: authoredSchema.parse(JSON.parse(bytes[0]!)),
46
+ trace: JSON.parse(bytes[1]!),
47
+ model: JSON.parse(bytes[2]!),
48
+ pins: pinsSchema.parse(JSON.parse(bytes[3]!)),
49
+ contentHash: createHash("sha256")
50
+ .update(JSON.stringify(bytes))
51
+ .digest("hex"),
52
+ };
53
+ }
54
+
55
+ /** Create the shipped document through the same strict validation as authored reviews. */
56
+ export async function createNativeTutorial(input: {
57
+ assetsRoot: string;
58
+ sampleRoot: string;
59
+ store: ReviewStore;
60
+ data: LocalReviewData;
61
+ }) {
62
+ const assets = await readTutorialAssets(input.assetsRoot);
63
+ const repository = await input.data.register(input.sampleRoot);
64
+
65
+ const pins = await input.data.resolvePins(
66
+ repository.id,
67
+ assets.pins.base,
68
+ assets.pins.head,
69
+ );
70
+
71
+ const aliases = new Map<string, string>();
72
+ const traceId = randomUUID();
73
+ await input.data.upload({
74
+ id: traceId,
75
+ repositoryId: repository.id,
76
+ kind: "trace",
77
+ trace: assets.trace,
78
+ });
79
+ aliases.set("tutorial-trace", traceId);
80
+
81
+ for (const side of ["base", "head"] as const) {
82
+ const id = randomUUID();
83
+ await input.data.upload({
84
+ id,
85
+ repositoryId: repository.id,
86
+ kind: "map",
87
+ pins,
88
+ side,
89
+ model: assets.model,
90
+ });
91
+ aliases.set(`tutorial-map-${side}`, id);
92
+ }
93
+
94
+ for (const block of resourceReferences(assets.authored.document)) {
95
+ if (block.type === "trace_quote")
96
+ block.traceId = aliases.get(block.traceId) ?? block.traceId;
97
+
98
+ if (block.type === "software_map")
99
+ block.mapVersionId =
100
+ aliases.get(block.mapVersionId) ?? block.mapVersionId;
101
+ }
102
+
103
+ const result = await input.store.execute(
104
+ {
105
+ operation: {
106
+ type: "create",
107
+ title: assets.authored.title,
108
+ target: { kind: "commits", ...pins },
109
+ },
110
+ },
111
+ { document: assets.authored.document, origin: { tutorial: true } },
112
+ );
113
+
114
+ return {
115
+ snapshot: input.store.read(result.reviewId),
116
+ contentHash: assets.contentHash,
117
+ };
118
+ }
119
+
120
+ export function createTutorialService(input: {
121
+ packageRoot: string;
122
+ store: ReviewStore;
123
+ data: LocalReviewData;
124
+ }) {
125
+ const tutorialRoot = path.join(devReviewHome(), "tutorial");
126
+ const sampleRoot = path.join(tutorialRoot, "sample-service");
127
+ const stampPath = path.join(tutorialRoot, "stamp.json");
128
+ const assetsRoot = path.join(input.packageRoot, "tutorial");
129
+
130
+ const readStamp = async () =>
131
+ stampSchema.safeParse(
132
+ await readFile(stampPath, "utf8")
133
+ .then((value) => JSON.parse(value))
134
+ .catch(() => null),
135
+ ).data;
136
+
137
+ async function find(): Promise<Snapshot | null> {
138
+ try {
139
+ const stamp = await readStamp();
140
+
141
+ if (!stamp || !input.store.has(stamp.reviewUuid)) return null;
142
+ const snapshot = input.store.read(stamp.reviewUuid);
143
+ const assets = await readTutorialAssets(assetsRoot);
144
+
145
+ const [head, base] = await Promise.all([
146
+ resolveRevision(sampleRoot, "HEAD"),
147
+ resolveRevision(sampleRoot, "HEAD^"),
148
+ ]);
149
+
150
+ return snapshot.origin?.tutorial &&
151
+ stamp.contentHash === assets.contentHash &&
152
+ head &&
153
+ base &&
154
+ snapshot.pins?.head === head.commit &&
155
+ snapshot.pins?.base === base.commit &&
156
+ head.commit === assets.pins.head &&
157
+ base.commit === assets.pins.base
158
+ ? snapshot
159
+ : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ async function cleanup() {
166
+ for (const reviewId of input.store.tutorialIds())
167
+ await input.store.execute({
168
+ operation: { type: "delete", reviewId },
169
+ });
170
+
171
+ // Windows cannot delete a checkout while its Git reader or watchers
172
+ // hold it open, and handles can outlive their close briefly.
173
+ for (const repository of input.store.repositories()) {
174
+ const relative = path.relative(tutorialRoot, repository.path);
175
+
176
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative))
177
+ await input.data.forgetRepository(repository.id);
178
+ }
179
+
180
+ await rm(tutorialRoot, { recursive: true, force: true, maxRetries: 10 });
181
+ }
182
+
183
+ return {
184
+ async status() {
185
+ return {
186
+ version: 1 as const,
187
+ reviewUuid: (await find())?.reviewId ?? null,
188
+ };
189
+ },
190
+ async referencesReview(id: string) {
191
+ return (
192
+ input.store.has(id) && input.store.read(id).origin?.tutorial === true
193
+ );
194
+ },
195
+ async prepare(options?: { beforeReset(): Promise<void> }) {
196
+ const current = await find();
197
+
198
+ if (current) return current;
199
+
200
+ await readTutorialAssets(assetsRoot);
201
+ await options?.beforeReset();
202
+ await cleanup();
203
+ await mkdir(tutorialRoot, { recursive: true, mode: 0o700 });
204
+
205
+ const temporaryRoot = path.join(
206
+ tutorialRoot,
207
+ `.sample-service-${randomUUID()}`,
208
+ );
209
+
210
+ try {
211
+ await cp(path.join(assetsRoot, "sample-service"), temporaryRoot, {
212
+ recursive: true,
213
+ });
214
+ await cp(
215
+ path.join(assetsRoot, "git-stub"),
216
+ path.join(temporaryRoot, ".git"),
217
+ { recursive: true },
218
+ );
219
+ await rename(temporaryRoot, sampleRoot);
220
+
221
+ const { snapshot, contentHash } = await createNativeTutorial({
222
+ assetsRoot,
223
+ sampleRoot,
224
+ store: input.store,
225
+ data: input.data,
226
+ });
227
+
228
+ await writePrivateJsonAtomic(stampPath, {
229
+ version: 10,
230
+ reviewUuid: snapshot.reviewId,
231
+ contentHash,
232
+ });
233
+
234
+ return snapshot;
235
+ } finally {
236
+ await rm(temporaryRoot, { recursive: true, force: true });
237
+ }
238
+ },
239
+ cleanup,
240
+ };
241
+ }