@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.
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
- package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
- package/dist/agent-cli-CRPPmS3l.js +2 -0
- package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
- package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
- package/dist/background-server-Cw7CcfaZ.js +156 -0
- package/dist/background-server-D2Rrr_e2.js +2 -0
- package/dist/build-info.json +1 -1
- package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
- package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
- package/dist/cli.js +3 -2
- package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
- package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
- package/dist/headless-host-Cp37C-v0.js +2 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
- package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
- package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
- package/dist/profile-BSOkA6A-.js +2 -0
- package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
- package/dist/remote-attach-BAt2eZx3.js +137 -0
- package/dist/remote-extensions-2Cxf3hMo.js +2 -0
- package/dist/remote-extensions-BiNx6NQK.js +323 -0
- package/dist/remote-language-server-DQgWUNeV.js +252 -0
- package/dist/remote-uninstall-DLtDG0o7.js +153 -0
- package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
- package/dist/review-app-launcher-D7I4DGoH.js +222 -0
- package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
- package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
- package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
- package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
- package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
- package/dist/server/desktop-host.js +1663 -266
- package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
- package/dist/sharing/index.d.ts +88 -32
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
- package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
- package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
- package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
- package/instructions/authoring.md +16 -12
- package/instructions/file-lenses.md +4 -2
- package/package.json +9 -14
- package/src/agent-connect.ts +507 -0
- package/src/agent-selection.ts +4 -0
- package/src/ask/agents.ts +59 -13
- package/src/ask/pi-mcp.ts +7 -3
- package/src/ask/thread-state.ts +9 -4
- package/src/ask/thread.ts +88 -50
- package/src/ask/threads.ts +53 -7
- package/src/cli-install.ts +3 -1
- package/src/cli-runner.ts +472 -81
- package/src/cli.ts +1 -1
- package/src/connect-agents.ts +285 -0
- package/src/connect-prompts.ts +60 -6
- package/src/desktop-discovery.ts +24 -0
- package/src/pinned-download.ts +118 -0
- package/src/remote-attach.ts +130 -0
- package/src/remote-extensions.ts +359 -0
- package/src/remote-language-server.ts +428 -0
- package/src/remote-toolchains.ts +107 -0
- package/src/remote-uninstall.ts +280 -0
- package/src/review-api/README.md +1 -3
- package/src/review-api/agent-cli.ts +4 -4
- package/src/review-api/agent-client.ts +23 -0
- package/src/review-api/ask-history.ts +31 -16
- package/src/review-api/authoring-tools.ts +1 -1
- package/src/review-api/blocks/index.ts +2 -4
- package/src/review-api/document.ts +1 -4
- package/src/review-api/http.ts +265 -237
- package/src/review-api/local-data.ts +18 -3
- package/src/review-api/onboarding.ts +478 -0
- package/src/review-api/origin.ts +7 -0
- package/src/review-api/recent-folders.ts +173 -0
- package/src/review-api/store.ts +56 -10
- package/src/review-api/workspaces.ts +56 -11
- package/src/review-app-launcher.ts +61 -3
- package/src/review-stack.ts +162 -36
- package/src/server/background-server.ts +250 -0
- package/src/server/desktop-host-shutdown.ts +24 -0
- package/src/server/desktop-host.ts +39 -24
- package/src/server/desktop-server.ts +68 -118
- package/src/server/global-verb-relay.ts +16 -0
- package/src/server/headless-host.ts +128 -3
- package/src/server/hono-http.ts +6 -1
- package/src/server/legacy-tutorial-cleanup.ts +38 -0
- package/src/server/review-gateway-hosts.ts +379 -0
- package/src/server/review-gateway-list.ts +109 -0
- package/src/server/review-gateway-memory.ts +130 -0
- package/src/server/review-gateway-pushes.ts +142 -0
- package/src/server/review-gateway-streams.ts +415 -0
- package/src/server/review-gateway-transport.ts +204 -0
- package/src/server/review-gateway.ts +433 -0
- package/src/server/review-server-core.ts +36 -6
- package/src/server-discovery.ts +11 -0
- package/src/sharing/import.ts +1 -0
- package/src/ui-telemetry-events.ts +5 -1
- package/src/vsix.ts +122 -0
- package/dist/agent-cli-BR_Jw5BL.js +0 -2
- package/dist/headless-host-BLLJ7Dyu.js +0 -95
- package/dist/profile-aMSw1oCM.js +0 -2
- package/dist/server-discovery-ZVi8Ay8J.js +0 -66
- package/src/fixtures/blocks/tutorial.json +0 -14
- package/src/review-api/blocks/tutorial.ts +0 -23
- package/src/server/tutorial-service.ts +0 -217
- package/tutorial/document.json +0 -216
- package/tutorial/lenses.json +0 -10
- package/tutorial/runtime-manifest.json +0 -11
- package/tutorial/sample-service/package.json +0 -9
- package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
- package/tutorial/sample-service/src/app.ts +0 -26
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
- package/tutorial/sample-service/src/database/schema.ts +0 -9
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
- package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
- package/tutorial/sample-service/src/orders/order-service.ts +0 -35
- package/tutorial/sample-service/src/orders/order.ts +0 -43
- package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
- package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
- package/tutorial/sample-service/tsconfig.json +0 -12
|
@@ -12,19 +12,22 @@ import {
|
|
|
12
12
|
type JsonValue,
|
|
13
13
|
REVIEW_DESKTOP_DISCOVERY_VERSION,
|
|
14
14
|
type ReviewCliInstallApplyResponse,
|
|
15
|
+
ReviewConnectRequestSchema,
|
|
15
16
|
type ReviewDesktopDiscovery,
|
|
16
|
-
type
|
|
17
|
+
type ReviewGatewayHost,
|
|
17
18
|
isJsonObject,
|
|
18
19
|
isObjectValue,
|
|
19
20
|
parseReviewCliInstallApplyRequest,
|
|
20
21
|
reviewDiffrSummarizerInputSchema,
|
|
21
22
|
} from "@dev.fast/review-protocol";
|
|
22
23
|
import { shellQuote, writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
23
|
-
import
|
|
24
|
+
import { connectAgent, detectConnectAgents } from "@review/agent-connect";
|
|
25
|
+
import { type AskTools, cliAskTools } from "@review/ask/threads.js";
|
|
24
26
|
import {
|
|
25
27
|
applyCliInstall,
|
|
26
28
|
declineCliInstall,
|
|
27
29
|
finishCliInstallUpdate,
|
|
30
|
+
pathShimPath,
|
|
28
31
|
removeCliInstall,
|
|
29
32
|
removeLegacyReviewSkills,
|
|
30
33
|
resetCliInstall,
|
|
@@ -52,6 +55,7 @@ import {
|
|
|
52
55
|
type ReviewTelemetryContext,
|
|
53
56
|
} from "@review/review-telemetry";
|
|
54
57
|
import type { SharedReviewStore } from "@review/sharing/import.js";
|
|
58
|
+
import { Hono } from "hono";
|
|
55
59
|
import { z } from "zod";
|
|
56
60
|
|
|
57
61
|
import { aliasInstallationToAccount } from "./account-alias";
|
|
@@ -67,18 +71,25 @@ import {
|
|
|
67
71
|
GlobalReviewDesktopVerbRelay,
|
|
68
72
|
type ReviewDesktopVerbRelay,
|
|
69
73
|
} from "./global-verb-relay";
|
|
70
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
type ReviewHonoEnv,
|
|
76
|
+
createNodeRequestListener,
|
|
77
|
+
readBoundedRequestJson,
|
|
78
|
+
} from "./hono-http";
|
|
71
79
|
import { ReviewServerError } from "./http-json";
|
|
72
80
|
import { createJsonReviewReporting } from "./json-review-reporting";
|
|
81
|
+
import { removeLegacyTutorial } from "./legacy-tutorial-cleanup";
|
|
82
|
+
import { createReviewGateway } from "./review-gateway";
|
|
73
83
|
import { reviewLifecycleTelemetry } from "./review-lifecycle-telemetry";
|
|
74
84
|
import { ReviewOpenWatchdog } from "./review-open-watchdog";
|
|
75
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
answerErrorsAsJson,
|
|
87
|
+
createWhiteboardCore,
|
|
88
|
+
serverJson,
|
|
89
|
+
} from "./review-server-core";
|
|
76
90
|
import { invalidateStructuralComparisons } from "./structural-comparisons.js";
|
|
77
|
-
import { createTutorialService } from "./tutorial-service";
|
|
78
91
|
import { captureSanitizedUiTelemetry } from "./ui-telemetry";
|
|
79
92
|
|
|
80
|
-
const TUTORIAL_LIFECYCLE_LOCK_KEY = "tutorial-lifecycle";
|
|
81
|
-
|
|
82
93
|
const UUID_PATTERN =
|
|
83
94
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
84
95
|
|
|
@@ -100,6 +111,8 @@ export interface GlobalReviewServerInput {
|
|
|
100
111
|
relay?: ReviewDesktopVerbRelay;
|
|
101
112
|
/** Electron's Review crash dump directory; `/crash-reports` reads only inside it. */
|
|
102
113
|
crashDumpsDir?: string;
|
|
114
|
+
onRemoteHostRestarted?(alias: string): void;
|
|
115
|
+
log?(message: string): void;
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
export interface GlobalReviewServer {
|
|
@@ -107,6 +120,7 @@ export interface GlobalReviewServer {
|
|
|
107
120
|
readonly url: string;
|
|
108
121
|
listen(): Promise<void>;
|
|
109
122
|
close(reason?: "app-exit"): Promise<void>;
|
|
123
|
+
setRemoteHosts(hosts: ReviewGatewayHost[]): void;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
export function createGlobalReviewServer(
|
|
@@ -135,45 +149,13 @@ export function createGlobalReviewServer(
|
|
|
135
149
|
const relay =
|
|
136
150
|
input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
|
|
137
151
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
...(process.versions.electron
|
|
143
|
-
? [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }]
|
|
144
|
-
: []),
|
|
145
|
-
...(process.env.DEV_REVIEW_HOME
|
|
146
|
-
? [{ name: "DEV_REVIEW_HOME", value: process.env.DEV_REVIEW_HOME }]
|
|
147
|
-
: []),
|
|
148
|
-
];
|
|
149
|
-
|
|
150
|
-
// Ask sessions get its MCP server, `whiteboard mcp`; an agent whose model
|
|
151
|
-
// would not get it uses `whiteboard api` from its shell instead.
|
|
152
|
-
const askTools: AskTools = {
|
|
153
|
-
mcpServers: () =>
|
|
154
|
-
discovery.cliPath
|
|
155
|
-
? [
|
|
156
|
-
{
|
|
157
|
-
name: "whiteboard",
|
|
158
|
-
command: process.execPath,
|
|
159
|
-
args: [discovery.cliPath, "mcp"],
|
|
160
|
-
env: askCliEnv(),
|
|
161
|
-
},
|
|
162
|
-
]
|
|
163
|
-
: [],
|
|
164
|
-
cli: () =>
|
|
165
|
-
discovery.cliPath &&
|
|
166
|
-
[
|
|
167
|
-
...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
|
|
168
|
-
shellQuote(process.execPath),
|
|
169
|
-
shellQuote(discovery.cliPath),
|
|
170
|
-
].join(" "),
|
|
171
|
-
};
|
|
152
|
+
const askTools = cliAskTools(() => discovery.cliPath, {
|
|
153
|
+
name: REVIEW_INSTANCE_ENV,
|
|
154
|
+
value: identity.key,
|
|
155
|
+
});
|
|
172
156
|
|
|
173
157
|
const reviewStore = input.reviewStore;
|
|
174
158
|
|
|
175
|
-
const reviewLocks = new Map<string, Promise<void>>();
|
|
176
|
-
|
|
177
159
|
// A reload or a second window starts the workbench again, but the launch
|
|
178
160
|
// this server belongs to became ready once.
|
|
179
161
|
let appReadyReported = false;
|
|
@@ -187,12 +169,6 @@ export function createGlobalReviewServer(
|
|
|
187
169
|
),
|
|
188
170
|
});
|
|
189
171
|
|
|
190
|
-
const tutorial = createTutorialService({
|
|
191
|
-
packageRoot: input.packageRoot,
|
|
192
|
-
store: reviewStore,
|
|
193
|
-
data: input.reviewData,
|
|
194
|
-
});
|
|
195
|
-
|
|
196
172
|
let closing = false;
|
|
197
173
|
const cliPath = path.join(input.packageRoot, "dist", "cli.js");
|
|
198
174
|
|
|
@@ -258,14 +234,31 @@ export function createGlobalReviewServer(
|
|
|
258
234
|
|
|
259
235
|
const { app } = core;
|
|
260
236
|
|
|
261
|
-
|
|
237
|
+
const localApi = new Hono<ReviewHonoEnv>();
|
|
238
|
+
answerErrorsAsJson(localApi);
|
|
239
|
+
|
|
240
|
+
localApi.route(
|
|
262
241
|
"/reviews-api",
|
|
263
242
|
createJsonReviewReporting(input.reviewStore, telemetry, {
|
|
264
243
|
shared: input.sharedReviews,
|
|
265
244
|
}),
|
|
266
245
|
);
|
|
267
246
|
|
|
268
|
-
|
|
247
|
+
localApi.route("/reviews-api", core.api);
|
|
248
|
+
|
|
249
|
+
const gateway = createReviewGateway({
|
|
250
|
+
local: (request) => localApi.fetch(request),
|
|
251
|
+
isLocal: (reviewId) => reviewStore.has(reviewId),
|
|
252
|
+
version: readReviewPackageVersion(import.meta.url),
|
|
253
|
+
home: devReviewHome(),
|
|
254
|
+
relay,
|
|
255
|
+
restarted: (alias) => input.onRemoteHostRestarted?.(alias),
|
|
256
|
+
log: input.log,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
|
|
260
|
+
app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
|
|
261
|
+
app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
|
|
269
262
|
app.get("/preferences/scratchpad", () =>
|
|
270
263
|
serverJson(200, { enabled: scratchpadEnabled }),
|
|
271
264
|
);
|
|
@@ -372,32 +365,6 @@ export function createGlobalReviewServer(
|
|
|
372
365
|
|
|
373
366
|
return serverJson(result.status, result.body);
|
|
374
367
|
});
|
|
375
|
-
app.get("/tutorial/status", async () =>
|
|
376
|
-
serverJson(200, await tutorial.status()),
|
|
377
|
-
);
|
|
378
|
-
app.post("/tutorial/prepare", async () => {
|
|
379
|
-
const prepared = await withReviewLock(
|
|
380
|
-
TUTORIAL_LIFECYCLE_LOCK_KEY,
|
|
381
|
-
prepareTutorialLocked,
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
return serverJson(200, {
|
|
385
|
-
ok: true,
|
|
386
|
-
reviewUuid: prepared.reviewId,
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
app.post("/tutorial/open", async () => {
|
|
390
|
-
return serverJson(
|
|
391
|
-
200,
|
|
392
|
-
await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, openTutorialLocked),
|
|
393
|
-
);
|
|
394
|
-
});
|
|
395
|
-
app.delete("/tutorial", async () => {
|
|
396
|
-
await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, deleteTutorialLocked);
|
|
397
|
-
|
|
398
|
-
return serverJson(200, { ok: true });
|
|
399
|
-
});
|
|
400
|
-
|
|
401
368
|
app.get("/diffr-config", async () =>
|
|
402
369
|
serverJson(200, await readDiffrConfig()),
|
|
403
370
|
);
|
|
@@ -517,50 +484,27 @@ export function createGlobalReviewServer(
|
|
|
517
484
|
|
|
518
485
|
return serverJson(200, { ok: true });
|
|
519
486
|
});
|
|
487
|
+
// The harnesses first run connects for the user, instead of asking them
|
|
488
|
+
// to paste a prompt into each one.
|
|
489
|
+
app.get("/install/agents", async () =>
|
|
490
|
+
serverJson(200, { agents: await detectConnectAgents() }),
|
|
491
|
+
);
|
|
492
|
+
app.post("/install/connect", async (context) => {
|
|
493
|
+
const request = ReviewConnectRequestSchema.safeParse(
|
|
494
|
+
await readBoundedRequestJson(context.req.raw),
|
|
495
|
+
);
|
|
520
496
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
async function prepareTutorialLocked() {
|
|
524
|
-
return tutorial.prepare();
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
async function openTutorialLocked(): Promise<ReviewTutorialOpenResponse> {
|
|
528
|
-
const snapshot = await prepareTutorialLocked();
|
|
529
|
-
|
|
530
|
-
return {
|
|
531
|
-
kind: "api",
|
|
532
|
-
reviewUuid: snapshot.reviewId,
|
|
533
|
-
title: snapshot.title,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
async function deleteTutorialLocked(): Promise<void> {
|
|
538
|
-
await tutorial.cleanup();
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async function withReviewLock<T>(
|
|
542
|
-
reviewUuid: string,
|
|
543
|
-
operation: () => Promise<T>,
|
|
544
|
-
): Promise<T> {
|
|
545
|
-
const previous = reviewLocks.get(reviewUuid) ?? Promise.resolve();
|
|
546
|
-
let release: () => void = () => {};
|
|
547
|
-
|
|
548
|
-
const current = new Promise<void>((resolve) => {
|
|
549
|
-
release = resolve;
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
const chain = previous.then(() => current);
|
|
553
|
-
reviewLocks.set(reviewUuid, chain);
|
|
554
|
-
await previous;
|
|
497
|
+
if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
|
|
555
498
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
499
|
+
return serverJson(
|
|
500
|
+
200,
|
|
501
|
+
await connectAgent(request.data.target, {
|
|
502
|
+
hasShim: existsSync(pathShimPath()),
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
});
|
|
560
506
|
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
}
|
|
507
|
+
const httpServer = createServer(createNodeRequestListener(app));
|
|
564
508
|
|
|
565
509
|
return {
|
|
566
510
|
discovery,
|
|
@@ -569,6 +513,10 @@ export function createGlobalReviewServer(
|
|
|
569
513
|
},
|
|
570
514
|
listen: async () => {
|
|
571
515
|
await migrateDiffrConfig();
|
|
516
|
+
await removeLegacyTutorial({
|
|
517
|
+
store: reviewStore,
|
|
518
|
+
data: input.reviewData,
|
|
519
|
+
});
|
|
572
520
|
scratchpadEnabled = await readScratchpadEnabled();
|
|
573
521
|
boundPort = await listen(httpServer, input.port);
|
|
574
522
|
discovery.url = urlForBoundPort();
|
|
@@ -587,8 +535,10 @@ export function createGlobalReviewServer(
|
|
|
587
535
|
openWatchdog.dispose();
|
|
588
536
|
|
|
589
537
|
await closeHttpServer(httpServer);
|
|
538
|
+
await gateway.close();
|
|
590
539
|
await telemetry.shutdown(1_500);
|
|
591
540
|
},
|
|
541
|
+
setRemoteHosts: (hosts) => gateway.setHosts(hosts),
|
|
592
542
|
};
|
|
593
543
|
}
|
|
594
544
|
|
|
@@ -35,6 +35,7 @@ export interface ReviewDesktopVerbRelay {
|
|
|
35
35
|
dispatch(value: JsonValue): Promise<ReviewVerbResponse>;
|
|
36
36
|
acceptResult(value: JsonValue): boolean;
|
|
37
37
|
close(): void;
|
|
38
|
+
onAttachedChange(listener: () => void): () => void;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -51,6 +52,7 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
|
|
|
51
52
|
private readonly pending = new Map<string, PendingVerb>();
|
|
52
53
|
private readonly timeoutMs: number;
|
|
53
54
|
private readonly maxClients: number;
|
|
55
|
+
private readonly attachedListeners = new Set<() => void>();
|
|
54
56
|
|
|
55
57
|
constructor(options: { timeoutMs?: number; maxClients?: number } = {}) {
|
|
56
58
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_VERB_TIMEOUT_MS;
|
|
@@ -73,9 +75,21 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
|
|
|
73
75
|
this.clients.set(writer, detach);
|
|
74
76
|
writer.signal.addEventListener("abort", detach, { once: true });
|
|
75
77
|
|
|
78
|
+
if (this.clients.size === 1) this.notifyAttached();
|
|
79
|
+
|
|
76
80
|
return true;
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
onAttachedChange(listener: () => void): () => void {
|
|
84
|
+
this.attachedListeners.add(listener);
|
|
85
|
+
|
|
86
|
+
return () => this.attachedListeners.delete(listener);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private notifyAttached(): void {
|
|
90
|
+
for (const listener of [...this.attachedListeners]) listener();
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
dispatch(value: JsonValue): Promise<ReviewVerbResponse> {
|
|
80
94
|
const request: ReviewVerbRequest = parseReviewVerbRequest(value);
|
|
81
95
|
|
|
@@ -170,6 +184,8 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
|
|
|
170
184
|
for (const [id, verb] of [...this.pending]) {
|
|
171
185
|
if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
|
|
172
186
|
}
|
|
187
|
+
|
|
188
|
+
if (this.clients.size === 0) this.notifyAttached();
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
/** Drops one client's id; with nobody left to answer, the verb fails. */
|
|
@@ -1,17 +1,32 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import { once } from "node:events";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
3
5
|
import { mkdir, realpath, rm } from "node:fs/promises";
|
|
4
6
|
import { createServer } from "node:http";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
9
|
|
|
6
10
|
import { isObjectValue } from "@dev.fast/json";
|
|
7
11
|
import { withFileLock, writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
12
|
+
import type { AskAgentLauncher } from "@review/ask/agents.js";
|
|
13
|
+
import { cliAskTools } from "@review/ask/threads.js";
|
|
14
|
+
import { desktopUsesHome } from "@review/desktop-discovery.js";
|
|
15
|
+
import { findReviewPackageRoot } from "@review/package-paths.js";
|
|
8
16
|
import { openReviewProfile } from "@review/review-api/profile.js";
|
|
9
17
|
import {
|
|
18
|
+
DESKTOP_TAKEOVER_SIGNAL,
|
|
10
19
|
type ReviewServerDiscovery,
|
|
20
|
+
desktopOwnsHome,
|
|
11
21
|
headlessServerLockPath,
|
|
22
|
+
readReviewServerDiscovery,
|
|
12
23
|
reviewServerDiscoveryPath,
|
|
13
24
|
} from "@review/server-discovery.js";
|
|
14
25
|
|
|
26
|
+
import {
|
|
27
|
+
headlessServerOwner,
|
|
28
|
+
stopBackgroundServer,
|
|
29
|
+
} from "./background-server.js";
|
|
15
30
|
import { migrateDiffrConfig } from "./diffr-config.js";
|
|
16
31
|
import { GlobalReviewDesktopVerbRelay } from "./global-verb-relay.js";
|
|
17
32
|
import { createNodeRequestListener } from "./hono-http.js";
|
|
@@ -26,12 +41,16 @@ interface HeadlessServerInput {
|
|
|
26
41
|
stateDir: string;
|
|
27
42
|
port?: number;
|
|
28
43
|
softwareMapEnabled?: boolean;
|
|
44
|
+
startedBy?: ReviewServerDiscovery["startedBy"];
|
|
29
45
|
signal: AbortSignal;
|
|
30
46
|
/** The CLI's instance, already on the `headless` surface. */
|
|
31
47
|
telemetry?: Pick<ReviewTelemetryCapture, "captureUiEvent">;
|
|
32
48
|
onReady(discovery: ReviewServerDiscovery): void;
|
|
49
|
+
launchAskAgent?: AskAgentLauncher;
|
|
33
50
|
}
|
|
34
51
|
|
|
52
|
+
export class HeadlessServerBusyError extends Error {}
|
|
53
|
+
|
|
35
54
|
/** One foreground headless endpoint per profile; Desktop shares its database. */
|
|
36
55
|
export async function runHeadlessServer(input: HeadlessServerInput) {
|
|
37
56
|
await mkdir(input.stateDir, { recursive: true, mode: 0o700 });
|
|
@@ -45,7 +64,7 @@ export async function runHeadlessServer(input: HeadlessServerInput) {
|
|
|
45
64
|
).finally(() => stopErrorTelemetry?.());
|
|
46
65
|
|
|
47
66
|
if (!outcome.acquired)
|
|
48
|
-
throw new
|
|
67
|
+
throw new HeadlessServerBusyError(
|
|
49
68
|
`A Whiteboard server already owns ${stateDir}. Stop it first, or choose another --state-dir.`,
|
|
50
69
|
);
|
|
51
70
|
}
|
|
@@ -70,15 +89,104 @@ export function withHeadlessServerLock<T>(
|
|
|
70
89
|
);
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Desktop owns its home: it stops any server there, then starts under that
|
|
94
|
+
* server's lock, so none can start until Desktop has published its discovery.
|
|
95
|
+
*/
|
|
96
|
+
export async function takeOverServerHome<T>(
|
|
97
|
+
home: string,
|
|
98
|
+
start: () => Promise<T>,
|
|
99
|
+
): Promise<T> {
|
|
100
|
+
const stateDir = await realpath(home);
|
|
101
|
+
const deadline = Date.now() + 30_000;
|
|
102
|
+
|
|
103
|
+
while (true) {
|
|
104
|
+
const outcome = await withHeadlessServerLock(stateDir, start);
|
|
105
|
+
|
|
106
|
+
if (outcome.acquired) return outcome.result;
|
|
107
|
+
|
|
108
|
+
const owner = await headlessServerOwner(stateDir);
|
|
109
|
+
|
|
110
|
+
const discovery = await readReviewServerDiscovery(stateDir).catch(
|
|
111
|
+
() => null,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (owner !== undefined && discovery?.serverPid === owner)
|
|
115
|
+
await stopBackgroundServer(discovery, DESKTOP_TAKEOVER_SIGNAL);
|
|
116
|
+
else if (Date.now() > deadline)
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Process ${owner} holds the Whiteboard server's lock in ${stateDir} without serving it.`,
|
|
119
|
+
);
|
|
120
|
+
else await delay(100);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const LOGIN_PATH_TIMEOUT_MS = 4_000;
|
|
125
|
+
|
|
126
|
+
/** Started over ssh, this server has sshd's PATH; agents are on the login
|
|
127
|
+
* shell's. */
|
|
128
|
+
async function agentEnv(): Promise<NodeJS.ProcessEnv> {
|
|
129
|
+
const login = process.env.SHELL && (await loginPath(process.env.SHELL));
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...process.env,
|
|
133
|
+
PATH: [login, process.env.PATH, path.dirname(process.execPath)]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(path.delimiter),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function loginPath(shell: string) {
|
|
140
|
+
return new Promise<string | undefined>((resolve) => {
|
|
141
|
+
const child = spawn(
|
|
142
|
+
shell,
|
|
143
|
+
["-lic", 'printf "\\nWHITEBOARD-PATH=%s\\n" "$PATH"'],
|
|
144
|
+
{ detached: true, stdio: ["ignore", "pipe", "ignore"] },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
let output = "";
|
|
148
|
+
|
|
149
|
+
child.stdout.setEncoding("utf8");
|
|
150
|
+
child.stdout.on("data", (chunk: string) => (output += chunk));
|
|
151
|
+
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
155
|
+
} catch {}
|
|
156
|
+
|
|
157
|
+
resolve(undefined);
|
|
158
|
+
}, LOGIN_PATH_TIMEOUT_MS);
|
|
159
|
+
|
|
160
|
+
child.once("error", () => {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
resolve(undefined);
|
|
163
|
+
});
|
|
164
|
+
child.once("close", () => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
|
|
167
|
+
const found = output
|
|
168
|
+
.replaceAll("\r", "")
|
|
169
|
+
.split("\n")
|
|
170
|
+
.findLast((line) => line.startsWith("WHITEBOARD-PATH="))
|
|
171
|
+
?.slice("WHITEBOARD-PATH=".length);
|
|
172
|
+
|
|
173
|
+
resolve(found?.startsWith("/") ? found : undefined);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
73
178
|
async function serve(input: HeadlessServerInput) {
|
|
74
179
|
if (input.signal.aborted) return;
|
|
75
180
|
|
|
181
|
+
if (await desktopUsesHome(input.stateDir))
|
|
182
|
+
throw new HeadlessServerBusyError(desktopOwnsHome(input.stateDir));
|
|
183
|
+
|
|
76
184
|
await migrateDiffrConfig(input.signal);
|
|
77
185
|
|
|
78
186
|
if (input.telemetry) await drainServerCrashReport(input.telemetry);
|
|
79
187
|
|
|
80
188
|
const local = await openReviewProfile(input.stateDir, {
|
|
81
|
-
manageWorkspaces:
|
|
189
|
+
manageWorkspaces: true,
|
|
82
190
|
});
|
|
83
191
|
|
|
84
192
|
const discovery: ReviewServerDiscovery = {
|
|
@@ -87,11 +195,18 @@ async function serve(input: HeadlessServerInput) {
|
|
|
87
195
|
url: "http://127.0.0.1:0",
|
|
88
196
|
serverPid: process.pid,
|
|
89
197
|
token: randomBytes(32).toString("base64url"),
|
|
198
|
+
startedBy: input.startedBy ?? "user",
|
|
90
199
|
};
|
|
91
200
|
|
|
92
201
|
const relay = new GlobalReviewDesktopVerbRelay();
|
|
93
202
|
|
|
94
|
-
const
|
|
203
|
+
const cliPath = path.join(
|
|
204
|
+
findReviewPackageRoot(import.meta.url),
|
|
205
|
+
"dist",
|
|
206
|
+
"cli.js",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const { app, api, close } = createWhiteboardCore({
|
|
95
210
|
profile: local,
|
|
96
211
|
relay,
|
|
97
212
|
token: discovery.token,
|
|
@@ -100,6 +215,14 @@ async function serve(input: HeadlessServerInput) {
|
|
|
100
215
|
// The scratchpad is the laptop's alone, even with a Desktop attached.
|
|
101
216
|
scratchpad: () => false,
|
|
102
217
|
status: () => ({ key: "headless", home: input.stateDir }),
|
|
218
|
+
ask: {
|
|
219
|
+
tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
|
|
220
|
+
name: "DEV_REVIEW_SERVER_DIR",
|
|
221
|
+
value: input.stateDir,
|
|
222
|
+
}),
|
|
223
|
+
launch: input.launchAskAgent,
|
|
224
|
+
env: agentEnv(),
|
|
225
|
+
},
|
|
103
226
|
});
|
|
104
227
|
|
|
105
228
|
app.route("/reviews-api", api);
|
|
@@ -129,6 +252,8 @@ async function serve(input: HeadlessServerInput) {
|
|
|
129
252
|
input.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
130
253
|
});
|
|
131
254
|
} finally {
|
|
255
|
+
close();
|
|
256
|
+
|
|
132
257
|
// Watch streams may live forever. Drain ordinary requests, then bound shutdown.
|
|
133
258
|
const forceClose = setTimeout(() => server.closeAllConnections(), 5_000);
|
|
134
259
|
forceClose.unref();
|
package/src/server/hono-http.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type JsonValue,
|
|
6
|
+
REVIEW_HOST_HEADER,
|
|
7
|
+
parseJsonText,
|
|
8
|
+
} from "@dev.fast/review-protocol";
|
|
5
9
|
import { type HttpBindings, getRequestListener } from "@hono/node-server";
|
|
6
10
|
import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
|
|
7
11
|
import type { Hono } from "hono";
|
|
@@ -76,6 +80,7 @@ export function applyCorsHeaders(
|
|
|
76
80
|
"GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
77
81
|
);
|
|
78
82
|
response.headers.set("access-control-allow-private-network", "true");
|
|
83
|
+
response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
|
|
79
84
|
|
|
80
85
|
return response;
|
|
81
86
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { LocalReviewData } from "@review/review-api/local-data";
|
|
5
|
+
import type { ReviewStore } from "@review/review-api/store";
|
|
6
|
+
import { devReviewHome } from "@review/review-home-paths";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Earlier versions shipped a three-minute tutorial: a managed review over a
|
|
10
|
+
* sample checkout under ~/.dev/tutorial. Whiteboard no longer has one, so
|
|
11
|
+
* remove what an earlier version left behind. Best effort: a failure leaves
|
|
12
|
+
* it for the next start.
|
|
13
|
+
*/
|
|
14
|
+
export async function removeLegacyTutorial(input: {
|
|
15
|
+
store: ReviewStore;
|
|
16
|
+
data: LocalReviewData;
|
|
17
|
+
root?: string;
|
|
18
|
+
}): Promise<void> {
|
|
19
|
+
const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
for (const reviewId of input.store.legacyTutorialIds())
|
|
23
|
+
await input.store.execute({ operation: { type: "delete", reviewId } });
|
|
24
|
+
|
|
25
|
+
for (const repository of input.store.repositories()) {
|
|
26
|
+
const relative = path.relative(tutorialRoot, repository.path);
|
|
27
|
+
|
|
28
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
|
29
|
+
await input.data.forgetRepository(repository.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Windows cannot delete a checkout while its Git reader or watchers
|
|
33
|
+
// hold it open, and handles can outlive their close briefly.
|
|
34
|
+
await rm(tutorialRoot, { recursive: true, force: true, maxRetries: 10 });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn("[Whiteboard] Removing the old tutorial failed:", error);
|
|
37
|
+
}
|
|
38
|
+
}
|