@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.115

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 (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +9 -14
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
+ import { defaultBranch, git } from "@dev.fast/local-vcs";
4
5
  import type {
5
6
  ReviewApiSummary,
6
7
  ReviewStackLayer,
@@ -22,14 +23,71 @@ const GitHubStacksSchema = z.array(
22
23
 
23
24
  export type RunGitHubApi = (host: string, endpoint: string) => Promise<string>;
24
25
 
26
+ type Subject = Pick<ReviewApiSummary, "origin"> & { reviewId?: string };
27
+
28
+ type Candidate = Pick<
29
+ ReviewApiSummary,
30
+ "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "repositoryGroup"
31
+ >;
32
+
33
+ interface StackEntry {
34
+ branch: string;
35
+ pullRequestNumber: number | null;
36
+ pullRequestUrl: string | null;
37
+ base: string | null;
38
+ subject: string | null;
39
+ current: boolean;
40
+ reviews: readonly Candidate[];
41
+ }
42
+
43
+ /** The review's GitHub stack, else the chain of reviews through its base. */
25
44
  export async function resolveReviewStackLayers(
26
- subject: Pick<ReviewApiSummary, "origin"> & { reviewId?: string },
27
- reviews: readonly Pick<
28
- ReviewApiSummary,
29
- "reviewId" | "title" | "origin" | "pins" | "trashedAt"
30
- >[],
31
- runGitHubApi: RunGitHubApi = defaultRunGitHubApi,
45
+ subject: Subject,
46
+ reviews: readonly Candidate[],
47
+ options: { runGitHubApi?: RunGitHubApi; repositoryPath?: string } = {},
32
48
  ): Promise<ReviewStackLayer[]> {
49
+ let entries = await gitHubStack(
50
+ subject,
51
+ reviews,
52
+ options.runGitHubApi ?? defaultRunGitHubApi,
53
+ );
54
+
55
+ if (!entries.length && options.repositoryPath)
56
+ entries = await localStack(subject, reviews, options.repositoryPath);
57
+
58
+ const currentIndex = entries.findIndex((entry) => entry.current);
59
+
60
+ return entries.map((entry, index) => {
61
+ const review =
62
+ entry.reviews.find(
63
+ (candidate) => candidate.reviewId === subject.reviewId,
64
+ ) ??
65
+ entry.reviews.findLast((candidate) => !candidate.trashedAt) ??
66
+ entry.reviews.at(-1);
67
+
68
+ return {
69
+ branch: entry.branch,
70
+ pullRequestNumber: entry.pullRequestNumber,
71
+ pullRequestUrl: entry.pullRequestUrl,
72
+ base: entry.base,
73
+ subject: entry.subject,
74
+ reviewUuid: review?.reviewId ?? null,
75
+ reviewTitle: review?.title ?? null,
76
+ relation:
77
+ index < currentIndex
78
+ ? "earlier"
79
+ : index === currentIndex
80
+ ? "current"
81
+ : "later",
82
+ };
83
+ });
84
+ }
85
+
86
+ async function gitHubStack(
87
+ subject: Subject,
88
+ reviews: readonly Candidate[],
89
+ runGitHubApi: RunGitHubApi,
90
+ ): Promise<StackEntry[]> {
33
91
  const binding = subject.origin?.pullRequestUrl?.match(
34
92
  /^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/,
35
93
  );
@@ -59,43 +117,111 @@ export async function resolveReviewStackLayers(
59
117
  candidate.pull_requests.some((pr) => pr.number === pullRequestNumber),
60
118
  );
61
119
 
62
- if (!stack) return [];
120
+ return (stack?.pull_requests ?? []).map((pr) => ({
121
+ branch: pr.head.ref,
122
+ pullRequestNumber: pr.number,
123
+ pullRequestUrl: `${repoKey}/pull/${pr.number}`,
124
+ base: null,
125
+ subject: null,
126
+ current: pr.number === pullRequestNumber,
127
+ reviews: reviews.filter(
128
+ (candidate) =>
129
+ (candidate.origin?.pullRequestUrl?.replace(/\/pull\/\d+.*$/, "") ??
130
+ candidate.pins?.repositoryId) === repoKey &&
131
+ candidate.origin?.pullRequestNumber === pr.number,
132
+ ),
133
+ }));
134
+ }
135
+
136
+ /** The chain of reviews through this one, linked by the base each names. */
137
+ async function localStack(
138
+ subject: Subject,
139
+ reviews: readonly Candidate[],
140
+ repositoryPath: string,
141
+ ): Promise<StackEntry[]> {
142
+ const group = reviews.find((review) => review.reviewId === subject.reviewId)
143
+ ?.repositoryGroup?.key;
144
+
145
+ const current = subject.origin?.branch;
146
+
147
+ if (!group || !current) return [];
148
+
149
+ const main = subject.origin?.baseBranch
150
+ ? await defaultBranch(repositoryPath).catch(() => null)
151
+ : null;
152
+
153
+ const name = (ref: string) => ref.replace(/^origin\//, "");
63
154
 
64
- const currentIndex = stack.pull_requests.findIndex(
65
- (pr) => pr.number === pullRequestNumber,
155
+ const inGroup = reviews.filter(
156
+ (review) => review.repositoryGroup?.key === group,
66
157
  );
67
158
 
68
- return stack.pull_requests.map((pr, index) => {
69
- const matches = reviews.filter((candidate) => {
70
- const repository =
71
- candidate.origin?.pullRequestUrl?.replace(/\/pull\/\d+.*$/, "") ??
72
- candidate.pins?.repositoryId;
159
+ const latest = (field: "branch" | "baseBranch", branch: string) => {
160
+ const matches = inGroup.filter(
161
+ (review) => review.origin?.[field] === branch,
162
+ );
73
163
 
74
- return (
75
- repository === repoKey &&
76
- candidate.origin?.pullRequestNumber === pr.number
77
- );
78
- });
164
+ return matches.findLast((review) => !review.trashedAt) ?? matches.at(-1);
165
+ };
166
+
167
+ const branches = [current];
168
+
169
+ for (
170
+ let base = subject.origin?.baseBranch;
171
+ base && name(base) !== (main && name(main.ref)) && !branches.includes(base);
172
+ base = latest("branch", base)?.origin?.baseBranch
173
+ )
174
+ branches.unshift(base);
175
+
176
+ for (
177
+ let child = latest("baseBranch", current)?.origin?.branch;
178
+ child && !branches.includes(child);
179
+ child = latest("baseBranch", child)?.origin?.branch
180
+ )
181
+ branches.push(child);
182
+
183
+ const bottom = branches[0]!;
184
+
185
+ // A base nobody reviewed gets a row; a new review of it starts at its fork.
186
+ const start =
187
+ latest("branch", bottom) || !main
188
+ ? null
189
+ : await forkPoint(repositoryPath, main.commit, bottom);
190
+
191
+ if (!latest("branch", bottom) && !start) branches.shift();
192
+
193
+ if (branches.length < 2) return [];
194
+
195
+ return branches.map((branch) => ({
196
+ branch,
197
+ pullRequestNumber: null,
198
+ pullRequestUrl: null,
199
+ base: (branch === bottom && start?.base) || null,
200
+ subject: (branch === bottom && start?.subject) || null,
201
+ current: branch === current,
202
+ reviews: inGroup.filter((review) => review.origin?.branch === branch),
203
+ }));
204
+ }
79
205
 
80
- const review =
81
- matches.find((candidate) => candidate.reviewId === subject.reviewId) ??
82
- matches.findLast((candidate) => !candidate.trashedAt) ??
83
- matches.at(-1);
206
+ /** Where a local branch left `main`, and its oldest commit's subject. */
207
+ async function forkPoint(repositoryPath: string, main: string, branch: string) {
208
+ const ref = `refs/heads/${branch}`;
84
209
 
85
- return {
86
- branch: pr.head.ref,
87
- pullRequestNumber: pr.number,
88
- pullRequestUrl: `${repoKey}/pull/${pr.number}`,
89
- reviewUuid: review?.reviewId ?? null,
90
- reviewTitle: review?.title ?? null,
91
- relation:
92
- index < currentIndex
93
- ? "earlier"
94
- : index === currentIndex
95
- ? "current"
96
- : "later",
97
- };
210
+ const fork = await git(repositoryPath, ["merge-base", main, ref], {
211
+ allowFailure: true,
98
212
  });
213
+
214
+ if (!fork.ok) return null;
215
+
216
+ const base = fork.stdout.trim();
217
+
218
+ const log = await git(
219
+ repositoryPath,
220
+ ["log", "--reverse", "--format=%s", `${base}..${ref}`],
221
+ { allowFailure: true },
222
+ );
223
+
224
+ return { base, subject: log.stdout.split("\n")[0] || null };
99
225
  }
100
226
 
101
227
  async function defaultRunGitHubApi(
@@ -0,0 +1,250 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, open, readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+
6
+ import { liveLockOwner, processIsAlive } from "@dev.fast/trace-core";
7
+ import {
8
+ type ReviewInstanceSelection,
9
+ desktopUsesHome,
10
+ } from "@review/desktop-discovery.js";
11
+ import { findReviewPackageRoot } from "@review/package-paths.js";
12
+ import { desktopApplicationInstalled } from "@review/review-app-launcher.js";
13
+ import {
14
+ type ReviewServerDiscovery,
15
+ desktopOwnsHome,
16
+ headlessServerLockPath,
17
+ readReviewServerDiscovery,
18
+ readReviewServerHealth,
19
+ reviewServerStateDir,
20
+ } from "@review/server-discovery.js";
21
+
22
+ export interface EnsureBackgroundServerInput {
23
+ stateDir: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ timeoutMs?: number;
26
+ startedBy?: "cli" | "desktop";
27
+ args?: readonly string[];
28
+ cli?: readonly string[];
29
+ }
30
+
31
+ export function backgroundServerLogPath(stateDir: string) {
32
+ return path.join(stateDir, "review-server", "server.log");
33
+ }
34
+
35
+ export async function ensureBackgroundServer(
36
+ input: EnsureBackgroundServerInput,
37
+ ): Promise<{ discovery: ReviewServerDiscovery; started: boolean }> {
38
+ const stateDir = path.resolve(input.stateDir);
39
+ const running = await healthyDiscovery(stateDir);
40
+
41
+ if (running) return { discovery: running, started: false };
42
+
43
+ if (await desktopUsesHome(stateDir, input.env))
44
+ throw new Error(desktopOwnsHome(stateDir));
45
+
46
+ const logPath = backgroundServerLogPath(stateDir);
47
+ await mkdir(path.dirname(logPath), { recursive: true, mode: 0o700 });
48
+ const logStart = (await stat(logPath).catch(() => null))?.size ?? 0;
49
+ const deadline = Date.now() + (input.timeoutMs ?? 15_000);
50
+ let child = await spawnServer(stateDir, logPath, input);
51
+ let respawned = false;
52
+
53
+ while (Date.now() < deadline) {
54
+ const discovery = await healthyDiscovery(stateDir);
55
+
56
+ if (discovery)
57
+ return { discovery, started: discovery.serverPid === child.pid };
58
+
59
+ if (child.error)
60
+ throw new Error(
61
+ `Could not start the Whiteboard server: ${child.error.message}`,
62
+ );
63
+
64
+ if (child.exited && (await headlessServerOwner(stateDir)) === undefined) {
65
+ if (respawned) break;
66
+ respawned = true;
67
+ child = await spawnServer(stateDir, logPath, input);
68
+ }
69
+
70
+ await delay(100);
71
+ }
72
+
73
+ const owner = await headlessServerOwner(stateDir);
74
+
75
+ throw new Error(
76
+ `The Whiteboard server did not become ready${child.exited ? "" : ` within ${Math.round((input.timeoutMs ?? 15_000) / 1_000)} s; process ${child.pid} is still starting`}.${owner !== undefined && owner !== child.pid ? ` Process ${owner} holds its state directory without answering; \`whiteboard server stop\` ends it.` : ""} The end of ${logPath}:\n${await logTail(logPath, logStart)}`,
77
+ );
78
+ }
79
+
80
+ export async function ensureServerWithoutDesktop(input: {
81
+ selection: ReviewInstanceSelection;
82
+ env: NodeJS.ProcessEnv;
83
+ desktopInstalled?: () => boolean;
84
+ cli?: readonly string[];
85
+ }) {
86
+ const { selection, env } = input;
87
+
88
+ if (
89
+ selection.source !== "fallback" ||
90
+ selection.instances.length !== 0 ||
91
+ selection.problem ||
92
+ (input.desktopInstalled ?? (() => desktopApplicationInstalled({ env })))()
93
+ )
94
+ return undefined;
95
+
96
+ const { discovery } = await ensureBackgroundServer({
97
+ stateDir: reviewServerStateDir(env),
98
+ env,
99
+ cli: input.cli,
100
+ });
101
+
102
+ return discovery;
103
+ }
104
+
105
+ export async function headlessServerOwner(stateDir: string) {
106
+ const resolved = await realpath(stateDir).catch(() => path.resolve(stateDir));
107
+
108
+ return liveLockOwner(headlessServerLockPath(resolved));
109
+ }
110
+
111
+ export async function recordedBackgroundServer(stateDir: string) {
112
+ const discovery = await readReviewServerDiscovery(stateDir).catch(() => null);
113
+ const health = discovery && (await readReviewServerHealth(discovery));
114
+
115
+ return discovery && health?.serverPid === discovery.serverPid
116
+ ? { discovery, health }
117
+ : undefined;
118
+ }
119
+
120
+ export async function stopBackgroundServer(
121
+ discovery: Pick<ReviewServerDiscovery, "serverPid">,
122
+ first: NodeJS.Signals = "SIGTERM",
123
+ ) {
124
+ const { serverPid } = discovery;
125
+
126
+ for (const [signal, waitMs] of [
127
+ [first, 10_000],
128
+ ["SIGKILL", 2_000],
129
+ ] as const) {
130
+ try {
131
+ process.kill(serverPid, signal);
132
+ } catch (error) {
133
+ if (error instanceof Error && "code" in error && error.code === "ESRCH")
134
+ return;
135
+ throw error;
136
+ }
137
+
138
+ for (let waited = 0; waited < waitMs; waited += 100) {
139
+ if (!processIsAlive(serverPid)) return;
140
+ await delay(100);
141
+ }
142
+ }
143
+
144
+ throw new Error(`The Whiteboard server (process ${serverPid}) did not stop.`);
145
+ }
146
+
147
+ interface DetachedChild {
148
+ pid?: number;
149
+ exited: boolean;
150
+ error?: Error;
151
+ }
152
+
153
+ export async function spawnDetached(input: {
154
+ command: string;
155
+ args: readonly string[];
156
+ cwd: string;
157
+ env: NodeJS.ProcessEnv;
158
+ log: string;
159
+ }) {
160
+ const log = await open(input.log, "a", 0o600);
161
+
162
+ const child = spawn(input.command, input.args, {
163
+ cwd: input.cwd,
164
+ detached: true,
165
+ env: input.env,
166
+ stdio: ["ignore", log.fd, log.fd],
167
+ });
168
+
169
+ const state: DetachedChild = { pid: child.pid, exited: false };
170
+
171
+ child.once("exit", () => (state.exited = true));
172
+ child.once("error", (error) => {
173
+ state.exited = true;
174
+ state.error = error;
175
+ });
176
+ child.unref();
177
+ await log.close();
178
+
179
+ return state;
180
+ }
181
+
182
+ function spawnServer(
183
+ stateDir: string,
184
+ logPath: string,
185
+ input: EnsureBackgroundServerInput,
186
+ ) {
187
+ const { command, args, env } = cliSpawn(input.cli, input.env ?? process.env);
188
+
189
+ return spawnDetached({
190
+ command,
191
+ args: [
192
+ ...args,
193
+ "server",
194
+ "start",
195
+ "--state-dir",
196
+ stateDir,
197
+ "--started-by",
198
+ input.startedBy ?? "cli",
199
+ ...(input.args ?? []),
200
+ ],
201
+ cwd: stateDir,
202
+ env,
203
+ log: logPath,
204
+ });
205
+ }
206
+
207
+ export function cliSpawn(
208
+ cli: readonly string[] | undefined,
209
+ env: NodeJS.ProcessEnv,
210
+ ) {
211
+ const [command, ...args] = cli ?? currentCli();
212
+
213
+ return {
214
+ command: command!,
215
+ args,
216
+ // Run from source, tsx finds the path aliases only through this.
217
+ env:
218
+ args.at(-1)?.endsWith(".ts") && !env.TSX_TSCONFIG_PATH
219
+ ? {
220
+ ...env,
221
+ TSX_TSCONFIG_PATH: path.join(
222
+ findReviewPackageRoot(import.meta.url),
223
+ "tsconfig.json",
224
+ ),
225
+ }
226
+ : env,
227
+ };
228
+ }
229
+
230
+ function currentCli() {
231
+ return [
232
+ process.execPath,
233
+ ...process.execArgv,
234
+ path.resolve(process.argv[1]!),
235
+ ];
236
+ }
237
+
238
+ async function healthyDiscovery(stateDir: string) {
239
+ const discovery = await readReviewServerDiscovery(stateDir).catch(() => null);
240
+
241
+ return discovery && (await readReviewServerHealth(discovery))
242
+ ? discovery
243
+ : null;
244
+ }
245
+
246
+ async function logTail(logPath: string, from: number) {
247
+ const text = (await readFile(logPath)).subarray(from).toString("utf8");
248
+
249
+ return text.trimEnd().split("\n").slice(-20).join("\n");
250
+ }
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type JsonValue,
3
+ type ReviewGatewayHost,
4
+ ReviewGatewayHostSchema,
3
5
  isJsonObject,
4
6
  jsonBoolean,
5
7
  jsonProperty,
@@ -9,12 +11,23 @@ import {
9
11
  interface DesktopHostMessagePort {
10
12
  on(event: "message", listener: (event: { data: JsonValue }) => void): void;
11
13
  off(event: "message", listener: (event: { data: JsonValue }) => void): void;
14
+ postMessage?(message: JsonValue): void;
12
15
  }
13
16
 
14
17
  interface DesktopHostProcess {
15
18
  parentPort?: DesktopHostMessagePort;
16
19
  on(event: "message", listener: (message: JsonValue) => void): void;
17
20
  off(event: "message", listener: (message: JsonValue) => void): void;
21
+ send?(message: JsonValue): void;
22
+ }
23
+
24
+ export function postToDesktop(
25
+ hostProcess: DesktopHostProcess,
26
+ message: JsonValue,
27
+ ): void {
28
+ if (hostProcess.parentPort?.postMessage)
29
+ hostProcess.parentPort.postMessage(message);
30
+ else hostProcess.send?.(message);
18
31
  }
19
32
 
20
33
  export function listenForDesktopHostShutdown(
@@ -22,6 +35,7 @@ export function listenForDesktopHostShutdown(
22
35
  onShutdown: () => void,
23
36
  onTelemetrySetting?: (enabled: boolean) => void,
24
37
  onStageRustAnalyzer?: (path: string) => void,
38
+ onRemoteHosts?: (hosts: ReviewGatewayHost[]) => void,
25
39
  ): () => void {
26
40
  const handleMessage = (message: JsonValue) => {
27
41
  if (!isJsonObject(message)) return;
@@ -45,6 +59,16 @@ export function listenForDesktopHostShutdown(
45
59
  const path = jsonString(jsonProperty(message, "path"));
46
60
 
47
61
  if (path) onStageRustAnalyzer?.(path);
62
+
63
+ return;
64
+ }
65
+
66
+ if (type === "remote-hosts") {
67
+ const hosts = ReviewGatewayHostSchema.array().safeParse(
68
+ jsonProperty(message, "hosts"),
69
+ );
70
+
71
+ if (hosts.success) onRemoteHosts?.(hosts.data);
48
72
  }
49
73
  };
50
74
 
@@ -12,8 +12,12 @@ import { ReviewTelemetry } from "@review/review-telemetry";
12
12
  import { SharedReviewStore } from "@review/sharing/import.js";
13
13
  import { reviewTelemetryChannel } from "@review/telemetry-config";
14
14
 
15
- import { listenForDesktopHostShutdown } from "./desktop-host-shutdown";
15
+ import {
16
+ listenForDesktopHostShutdown,
17
+ postToDesktop,
18
+ } from "./desktop-host-shutdown";
16
19
  import { createGlobalReviewServer } from "./desktop-server";
20
+ import { takeOverServerHome } from "./headless-host";
17
21
  import {
18
22
  drainServerCrashReport,
19
23
  installProcessErrorTelemetry,
@@ -74,34 +78,44 @@ export async function runDesktopHost(
74
78
  const home = devReviewHome(env);
75
79
  await mkdir(home, { recursive: true });
76
80
 
77
- const local = await openReviewProfile(home, {
78
- manageWorkspaces: true,
79
- });
81
+ const log = (message: string) =>
82
+ process.stdout.write(`[Whiteboard gateway] ${message}\n`);
83
+
84
+ const { local, shared, server } = await takeOverServerHome(home, async () => {
85
+ const local = await openReviewProfile(home, {
86
+ manageWorkspaces: true,
87
+ });
80
88
 
81
- // JSON is the sole user-review store. A failure is surfaced, never replaced
82
- // by a second catalog or an old document renderer.
89
+ // JSON is the sole user-review store. A failure is surfaced, never replaced
90
+ // by a second catalog or an old document renderer.
91
+
92
+ const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
93
+ shared.connect(local.store, local.data);
94
+ await shared.load();
95
+
96
+ const server = createGlobalReviewServer({
97
+ ...serverInput,
98
+ log,
99
+ sharedReviews: shared,
100
+ reviewStore: local.store,
101
+ reviewData: local.data,
102
+ cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
103
+ crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR,
104
+ onRemoteHostRestarted: (alias) =>
105
+ postToDesktop(process, { type: "remote-host-restarted", alias }),
106
+ });
83
107
 
84
- const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
85
- shared.connect(local.store, local.data);
86
- await shared.load();
108
+ try {
109
+ await server.listen();
110
+ } catch (error) {
111
+ await local?.data.close();
112
+ await local?.store.close();
113
+ throw error;
114
+ }
87
115
 
88
- const server = createGlobalReviewServer({
89
- ...serverInput,
90
- sharedReviews: shared,
91
- reviewStore: local.store,
92
- reviewData: local.data,
93
- cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
94
- crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR,
116
+ return { local, shared, server };
95
117
  });
96
118
 
97
- try {
98
- await server.listen();
99
- } catch (error) {
100
- await local?.data.close();
101
- await local?.store.close();
102
- throw error;
103
- }
104
-
105
119
  process.stdout.write(
106
120
  `${JSON.stringify({ event: "ready", ...server.discovery, installationId })}\n`,
107
121
  );
@@ -142,6 +156,7 @@ export async function runDesktopHost(
142
156
  env.DEV_FAST_REVIEW_RUST_ANALYZER = sourcePath;
143
157
  void stageRustAnalyzer();
144
158
  },
159
+ (hosts) => server.setRemoteHosts(hosts),
145
160
  );
146
161
  process.once("SIGINT", () => {
147
162
  void stop().then(() => process.exit(0));