@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
REVIEW_CLIENT_HEADER,
|
|
6
|
+
REVIEW_CLIENT_REMOTE,
|
|
7
|
+
} from "@dev.fast/review-protocol";
|
|
8
|
+
import { type Dispatcher, request } from "undici";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { StreamLimitError } from "./bounded-stream.js";
|
|
12
|
+
import type { GatewayHosts, GatewayRemote } from "./review-gateway-hosts.js";
|
|
13
|
+
|
|
14
|
+
export const FIRST_RETRY_MS = 500;
|
|
15
|
+
|
|
16
|
+
export const MAX_RETRY_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
export const FIRST_BYTE_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
const NO_ANSWER = `it did not answer within ${FIRST_BYTE_TIMEOUT_MS / 1_000} seconds`;
|
|
21
|
+
|
|
22
|
+
export const jitter = (ms: number) => ms * (0.75 + Math.random() * 0.5);
|
|
23
|
+
|
|
24
|
+
const STEADY_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
const MAX_LINE_CHARS = 64 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
29
|
+
delay(ms, undefined, { signal }).catch(() => undefined);
|
|
30
|
+
|
|
31
|
+
export async function reconnect(
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
attempt: () => Promise<void>,
|
|
34
|
+
) {
|
|
35
|
+
let wait = FIRST_RETRY_MS;
|
|
36
|
+
|
|
37
|
+
while (!signal.aborted) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
await attempt();
|
|
40
|
+
|
|
41
|
+
if (signal.aborted) return;
|
|
42
|
+
|
|
43
|
+
if (Date.now() - started >= STEADY_MS) wait = FIRST_RETRY_MS;
|
|
44
|
+
await sleep(jitter(wait), signal);
|
|
45
|
+
wait = Math.min(wait * 2, MAX_RETRY_MS);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readLines(
|
|
50
|
+
body: AsyncIterable<Uint8Array>,
|
|
51
|
+
line: (text: string) => void,
|
|
52
|
+
) {
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let pending = "";
|
|
55
|
+
|
|
56
|
+
for await (const chunk of body) {
|
|
57
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
58
|
+
let end: number;
|
|
59
|
+
|
|
60
|
+
while ((end = pending.indexOf("\n")) !== -1) {
|
|
61
|
+
line(pending.slice(0, end));
|
|
62
|
+
pending = pending.slice(end + 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (pending.length > MAX_LINE_CHARS) throw new Error("A line is too long.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function keepOpen(input: {
|
|
70
|
+
hosts: GatewayHosts;
|
|
71
|
+
remote: GatewayRemote;
|
|
72
|
+
path: string;
|
|
73
|
+
signal: AbortSignal;
|
|
74
|
+
read(body: AsyncIterable<Uint8Array>): Promise<void>;
|
|
75
|
+
}) {
|
|
76
|
+
const { remote, signal } = input;
|
|
77
|
+
|
|
78
|
+
void reconnect(signal, async () => {
|
|
79
|
+
let timedOut = false;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await send(remote, {
|
|
83
|
+
method: "GET",
|
|
84
|
+
path: input.path,
|
|
85
|
+
headers: remoteHeaders(remote),
|
|
86
|
+
signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (response.statusCode === 200) await input.read(response.body);
|
|
90
|
+
else await response.body.dump();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (signal.aborted) return;
|
|
96
|
+
|
|
97
|
+
if (timedOut) input.hosts.failed(remote, NO_ANSWER);
|
|
98
|
+
else input.hosts.recheck(remote);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const remoteHeaders = (remote: GatewayRemote) => ({
|
|
103
|
+
"x-review-token": remote.endpoint?.token ?? "",
|
|
104
|
+
[REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export async function send(
|
|
108
|
+
remote: GatewayRemote,
|
|
109
|
+
input: {
|
|
110
|
+
method: Dispatcher.HttpMethod;
|
|
111
|
+
path: string;
|
|
112
|
+
headers?: Record<string, string>;
|
|
113
|
+
body?: Buffer | Readable;
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
headersTimeout?: number;
|
|
116
|
+
},
|
|
117
|
+
retry = true,
|
|
118
|
+
): Promise<Dispatcher.ResponseData> {
|
|
119
|
+
if (!remote.endpoint) throw new Error(`${remote.alias} has no endpoint.`);
|
|
120
|
+
let response: Dispatcher.ResponseData;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
response = await request(new URL(input.path, remote.endpoint.url), {
|
|
124
|
+
dispatcher: remote.dispatcher,
|
|
125
|
+
method: input.method,
|
|
126
|
+
headers: input.headers,
|
|
127
|
+
body: input.body,
|
|
128
|
+
signal: input.signal,
|
|
129
|
+
headersTimeout: input.headersTimeout ?? FIRST_BYTE_TIMEOUT_MS,
|
|
130
|
+
bodyTimeout: 0,
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (
|
|
134
|
+
retry &&
|
|
135
|
+
!input.signal?.aborted &&
|
|
136
|
+
!(input.body instanceof Readable) &&
|
|
137
|
+
errorCode(error) === "UND_ERR_SOCKET" &&
|
|
138
|
+
(input.body === undefined || reusedSocket(error))
|
|
139
|
+
)
|
|
140
|
+
return send(remote, input, false);
|
|
141
|
+
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (response.statusCode === 401) remote.unauthorized?.();
|
|
146
|
+
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function readBody(
|
|
151
|
+
body: Dispatcher.ResponseData["body"],
|
|
152
|
+
limit: number,
|
|
153
|
+
) {
|
|
154
|
+
const parts: Buffer[] = [];
|
|
155
|
+
let size = 0;
|
|
156
|
+
|
|
157
|
+
for await (const part of body) {
|
|
158
|
+
// SAFETY: undici's body yields Buffers.
|
|
159
|
+
const chunk = part as Buffer;
|
|
160
|
+
size += chunk.byteLength;
|
|
161
|
+
|
|
162
|
+
if (size > limit) {
|
|
163
|
+
body.destroy();
|
|
164
|
+
throw new StreamLimitError();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
parts.push(chunk);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return Buffer.concat(parts, size);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const codedError = z.object({ code: z.string() });
|
|
174
|
+
|
|
175
|
+
const socketError = z.object({ socket: z.object({ bytesRead: z.number() }) });
|
|
176
|
+
|
|
177
|
+
// undici has no reusedSocket; a socket that already read bytes served an earlier response.
|
|
178
|
+
const reusedSocket = (cause: unknown) =>
|
|
179
|
+
(socketError.safeParse(cause).data?.socket.bytesRead ?? 0) > 0;
|
|
180
|
+
|
|
181
|
+
const ERROR_WORDS = new Map([
|
|
182
|
+
["ECONNREFUSED", "it refused the connection"],
|
|
183
|
+
["ECONNRESET", "it closed the connection"],
|
|
184
|
+
["UND_ERR_SOCKET", "it closed the connection"],
|
|
185
|
+
["EPIPE", "it closed the connection"],
|
|
186
|
+
["ETIMEDOUT", "it did not answer"],
|
|
187
|
+
["UND_ERR_HEADERS_TIMEOUT", NO_ANSWER],
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
export function errorCode(cause: unknown): string {
|
|
191
|
+
if (!(cause instanceof Error)) return String(cause);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
codedError.safeParse(cause).data?.code ??
|
|
195
|
+
codedError.safeParse(cause.cause).data?.code ??
|
|
196
|
+
cause.message
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function errorText(cause: unknown): string {
|
|
201
|
+
const code = errorCode(cause);
|
|
202
|
+
|
|
203
|
+
return ERROR_WORDS.get(code) ?? `it failed (${code})`;
|
|
204
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
REVIEW_CLIENT_HEADER,
|
|
6
|
+
REVIEW_HOST_HEADER,
|
|
7
|
+
type ReviewGatewayHost,
|
|
8
|
+
type ReviewGatewayHostState,
|
|
9
|
+
parseJsonText,
|
|
10
|
+
} from "@dev.fast/review-protocol";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import type { Dispatcher } from "undici";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import { StreamLimitError, readBoundedStream } from "./bounded-stream.js";
|
|
16
|
+
import type { ReviewDesktopVerbRelay } from "./global-verb-relay.js";
|
|
17
|
+
import { DEFAULT_MAX_REQUEST_BYTES } from "./http-json.js";
|
|
18
|
+
import {
|
|
19
|
+
type GatewayRemote,
|
|
20
|
+
createGatewayHosts,
|
|
21
|
+
} from "./review-gateway-hosts.js";
|
|
22
|
+
import { type ListSource, listedBy } from "./review-gateway-list.js";
|
|
23
|
+
import { UUID, openGatewayMemory } from "./review-gateway-memory.js";
|
|
24
|
+
import { createGatewayPushes } from "./review-gateway-pushes.js";
|
|
25
|
+
import {
|
|
26
|
+
type Located,
|
|
27
|
+
createGatewayStreams,
|
|
28
|
+
downDetail,
|
|
29
|
+
} from "./review-gateway-streams.js";
|
|
30
|
+
import {
|
|
31
|
+
FIRST_BYTE_TIMEOUT_MS,
|
|
32
|
+
errorCode,
|
|
33
|
+
errorText,
|
|
34
|
+
remoteHeaders,
|
|
35
|
+
send,
|
|
36
|
+
} from "./review-gateway-transport.js";
|
|
37
|
+
import { serverJson } from "./review-server-core.js";
|
|
38
|
+
|
|
39
|
+
const LOCAL_ROUTES = new Set([
|
|
40
|
+
"repositories",
|
|
41
|
+
"resources",
|
|
42
|
+
"workspace-cleanup",
|
|
43
|
+
"authoring",
|
|
44
|
+
"capabilities",
|
|
45
|
+
"status",
|
|
46
|
+
"instructions",
|
|
47
|
+
"sharing",
|
|
48
|
+
"commands",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
interface RemoteRoute {
|
|
52
|
+
method: string;
|
|
53
|
+
path: string;
|
|
54
|
+
/** May wait on preparing a checkout or launching an agent. */
|
|
55
|
+
slow?: true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const REMOTE_ROUTES: readonly RemoteRoute[] = [
|
|
59
|
+
{ method: "GET", path: "" },
|
|
60
|
+
{ method: "GET", path: "progress" },
|
|
61
|
+
{ method: "POST", path: "progress" },
|
|
62
|
+
{ method: "GET", path: "commits" },
|
|
63
|
+
{ method: "GET", path: "diff" },
|
|
64
|
+
{ method: "GET", path: "structural-diff" },
|
|
65
|
+
{ method: "GET", path: "stack" },
|
|
66
|
+
{ method: "GET", path: "tree" },
|
|
67
|
+
{ method: "GET", path: "file" },
|
|
68
|
+
{ method: "GET", path: "resources/:name" },
|
|
69
|
+
{ method: "GET", path: "maps/:name" },
|
|
70
|
+
{ method: "POST", path: "navigator" },
|
|
71
|
+
{ method: "POST", path: "copy-context" },
|
|
72
|
+
{ method: "GET", path: "language-context", slow: true },
|
|
73
|
+
{ method: "GET", path: "ask/agents" },
|
|
74
|
+
{ method: "GET", path: "ask/agents/:agent/offer", slow: true },
|
|
75
|
+
{ method: "GET", path: "ask/mentions", slow: true },
|
|
76
|
+
{ method: "GET", path: "ask/threads" },
|
|
77
|
+
{ method: "POST", path: "ask/watch" },
|
|
78
|
+
{ method: "GET", path: "ask/:thread" },
|
|
79
|
+
{ method: "POST", path: "ask", slow: true },
|
|
80
|
+
{
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: "ask/:thread/:action{open|choice|permissions}",
|
|
83
|
+
slow: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
method: "POST",
|
|
87
|
+
path: "ask/:thread/:action{prompt|permission|files|retry|cancel|close}",
|
|
88
|
+
},
|
|
89
|
+
{ method: "DELETE", path: "ask/:thread" },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const SLOW_ROUTE_TIMEOUT_MS = 120_000;
|
|
93
|
+
|
|
94
|
+
const LIST_WAIT_MS = 2_000;
|
|
95
|
+
|
|
96
|
+
const HOP_HEADERS = new Set([
|
|
97
|
+
"connection",
|
|
98
|
+
"keep-alive",
|
|
99
|
+
"transfer-encoding",
|
|
100
|
+
"content-length",
|
|
101
|
+
"te",
|
|
102
|
+
"trailer",
|
|
103
|
+
"upgrade",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const DROPPED_REQUEST_HEADERS = new Set([
|
|
107
|
+
...HOP_HEADERS,
|
|
108
|
+
"host",
|
|
109
|
+
"origin",
|
|
110
|
+
"accept-encoding",
|
|
111
|
+
"cookie",
|
|
112
|
+
"authorization",
|
|
113
|
+
"x-review-token",
|
|
114
|
+
REVIEW_CLIENT_HEADER,
|
|
115
|
+
REVIEW_HOST_HEADER,
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const commandTarget = z.object({
|
|
119
|
+
operation: z.object({ reviewId: z.string().optional() }).optional(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export function createReviewGateway(input: {
|
|
123
|
+
local(request: Request): Response | Promise<Response>;
|
|
124
|
+
isLocal(reviewId: string): boolean;
|
|
125
|
+
version: string;
|
|
126
|
+
home: string;
|
|
127
|
+
relay: ReviewDesktopVerbRelay;
|
|
128
|
+
heartbeatMs?: number;
|
|
129
|
+
slowRouteMs?: number;
|
|
130
|
+
restarted?(alias: string): void;
|
|
131
|
+
log?(message: string): void;
|
|
132
|
+
}) {
|
|
133
|
+
const log = input.log ?? (() => {});
|
|
134
|
+
const memory = openGatewayMemory(input.home, log);
|
|
135
|
+
let changing = false;
|
|
136
|
+
let closed = false;
|
|
137
|
+
|
|
138
|
+
const hosts = createGatewayHosts({
|
|
139
|
+
version: input.version,
|
|
140
|
+
log,
|
|
141
|
+
machine: (serverId, alias) => memory.rename(serverId, alias),
|
|
142
|
+
...(input.heartbeatMs !== undefined && { heartbeatMs: input.heartbeatMs }),
|
|
143
|
+
restarted: (alias) => input.restarted?.(alias),
|
|
144
|
+
changed() {
|
|
145
|
+
if (changing) return;
|
|
146
|
+
changing = true;
|
|
147
|
+
queueMicrotask(() => {
|
|
148
|
+
changing = false;
|
|
149
|
+
|
|
150
|
+
if (closed) return;
|
|
151
|
+
const online = new Set(hosts.online());
|
|
152
|
+
|
|
153
|
+
for (const [abort, remote] of streaming)
|
|
154
|
+
if (!online.has(remote)) abort.abort();
|
|
155
|
+
streams.changed();
|
|
156
|
+
pushes.changed();
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const streaming = new Map<AbortController, GatewayRemote>();
|
|
162
|
+
const waiting = new Set<() => void>();
|
|
163
|
+
|
|
164
|
+
const lists = (): ListSource => ({
|
|
165
|
+
states: hosts.states(),
|
|
166
|
+
serving: (serverId) => hosts.serving(serverId) !== undefined,
|
|
167
|
+
serverIdOf: memory.serverIdOf,
|
|
168
|
+
list: memory.list,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function locate(reviewId: string): Located {
|
|
172
|
+
if (!UUID.test(reviewId) || input.isLocal(reviewId)) return "local";
|
|
173
|
+
const listed = listedBy(reviewId, lists());
|
|
174
|
+
|
|
175
|
+
if (!listed) return undefined;
|
|
176
|
+
const remote = hosts.serving(listed.serverId);
|
|
177
|
+
|
|
178
|
+
return remote ? { remote } : { down: listed.state };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A review just created on a host may not be on its list yet.
|
|
182
|
+
function ownerOf(reviewId: string): Located | Promise<Located> {
|
|
183
|
+
const located = locate(reviewId);
|
|
184
|
+
|
|
185
|
+
if (located !== undefined || !hosts.online().length) return located;
|
|
186
|
+
const found = Promise.withResolvers<Located>();
|
|
187
|
+
|
|
188
|
+
const settle = (owner: Located) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
waiting.delete(check);
|
|
191
|
+
found.resolve(owner);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const check = () => {
|
|
195
|
+
const owner = locate(reviewId);
|
|
196
|
+
|
|
197
|
+
if (owner !== undefined) settle(owner);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const timer = setTimeout(() => settle(undefined), LIST_WAIT_MS);
|
|
201
|
+
waiting.add(check);
|
|
202
|
+
|
|
203
|
+
return found.promise;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function answer(
|
|
207
|
+
alias: string,
|
|
208
|
+
status: number,
|
|
209
|
+
body: { ok: boolean; error?: string },
|
|
210
|
+
) {
|
|
211
|
+
const response = serverJson(status, body);
|
|
212
|
+
response.headers.set(REVIEW_HOST_HEADER, alias);
|
|
213
|
+
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function unavailable(down: ReviewGatewayHostState) {
|
|
218
|
+
return answer(down.alias, 503, { ok: false, error: downDetail(down) });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function forward(
|
|
222
|
+
remote: GatewayRemote,
|
|
223
|
+
request: Request,
|
|
224
|
+
options: { route?: RemoteRoute; body?: Buffer },
|
|
225
|
+
): Promise<Response> {
|
|
226
|
+
const url = new URL(request.url);
|
|
227
|
+
const { route } = options;
|
|
228
|
+
|
|
229
|
+
// The local token never leaves this machine, in a header or the query.
|
|
230
|
+
if (url.searchParams.has("token")) url.searchParams.delete("token");
|
|
231
|
+
|
|
232
|
+
const headers: Record<string, string> = remoteHeaders(remote);
|
|
233
|
+
|
|
234
|
+
request.headers.forEach((value, key) => {
|
|
235
|
+
if (!DROPPED_REQUEST_HEADERS.has(key)) headers[key] = value;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const limit = route?.slow
|
|
239
|
+
? (input.slowRouteMs ?? SLOW_ROUTE_TIMEOUT_MS)
|
|
240
|
+
: FIRST_BYTE_TIMEOUT_MS;
|
|
241
|
+
|
|
242
|
+
const abort = new AbortController();
|
|
243
|
+
|
|
244
|
+
// SAFETY: Node's Request body is its own web stream; the DOM type only
|
|
245
|
+
// names the same object.
|
|
246
|
+
const stream = request.body as WebReadableStream | null;
|
|
247
|
+
let response: Dispatcher.ResponseData;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
response = await send(remote, {
|
|
251
|
+
method: request.method,
|
|
252
|
+
path: `${url.pathname}${url.search}`,
|
|
253
|
+
headers,
|
|
254
|
+
body: options.body ?? (stream ? Readable.fromWeb(stream) : undefined),
|
|
255
|
+
signal: AbortSignal.any([request.signal, abort.signal]),
|
|
256
|
+
headersTimeout: limit,
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
|
|
260
|
+
|
|
261
|
+
const reason = timedOut
|
|
262
|
+
? `it did not answer within ${limit / 1_000} seconds`
|
|
263
|
+
: errorText(error);
|
|
264
|
+
|
|
265
|
+
if (!route?.slow && !request.signal.aborted) hosts.failed(remote, reason);
|
|
266
|
+
|
|
267
|
+
return answer(remote.alias, timedOut ? 504 : 502, {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: `Could not reach ${remote.alias}: ${reason}.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { statusCode: status } = response;
|
|
274
|
+
const out = new Headers();
|
|
275
|
+
|
|
276
|
+
for (const [key, value] of Object.entries(response.headers))
|
|
277
|
+
if (
|
|
278
|
+
value !== undefined &&
|
|
279
|
+
!HOP_HEADERS.has(key) &&
|
|
280
|
+
!key.startsWith("access-control-")
|
|
281
|
+
)
|
|
282
|
+
out.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
283
|
+
|
|
284
|
+
out.set(REVIEW_HOST_HEADER, remote.alias);
|
|
285
|
+
|
|
286
|
+
if (request.method === "HEAD" || status === 204 || status === 304) {
|
|
287
|
+
await response.body.dump();
|
|
288
|
+
|
|
289
|
+
return new Response(null, { status, headers: out });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
streaming.set(abort, remote);
|
|
293
|
+
response.body.on("close", () => streaming.delete(abort));
|
|
294
|
+
|
|
295
|
+
// SAFETY: Node's Response takes its own web stream; the DOM type only
|
|
296
|
+
// names the same object.
|
|
297
|
+
const piped = Readable.toWeb(response.body) as ReadableStream<Uint8Array>;
|
|
298
|
+
|
|
299
|
+
return new Response(piped, { status, headers: out });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function command(request: Request): Promise<Response> {
|
|
303
|
+
if (!request.body) return input.local(request);
|
|
304
|
+
let body: Buffer;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
body = await readBoundedStream(request.body, DEFAULT_MAX_REQUEST_BYTES);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (!(error instanceof StreamLimitError)) throw error;
|
|
310
|
+
|
|
311
|
+
return serverJson(413, {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: "Request body exceeds 1 MiB.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let reviewId: string | undefined;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
reviewId = commandTarget.safeParse(parseJsonText(body.toString())).data
|
|
321
|
+
?.operation?.reviewId;
|
|
322
|
+
} catch {}
|
|
323
|
+
|
|
324
|
+
const owner = reviewId === undefined ? undefined : await ownerOf(reviewId);
|
|
325
|
+
|
|
326
|
+
if (owner === undefined || owner === "local")
|
|
327
|
+
return input.local(
|
|
328
|
+
new Request(request.url, {
|
|
329
|
+
method: request.method,
|
|
330
|
+
headers: request.headers,
|
|
331
|
+
body: new Uint8Array(body),
|
|
332
|
+
signal: request.signal,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if ("down" in owner) return unavailable(owner.down);
|
|
337
|
+
|
|
338
|
+
return forward(owner.remote, request, { body });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function review(
|
|
342
|
+
request: Request,
|
|
343
|
+
reviewId: string,
|
|
344
|
+
route?: RemoteRoute | "telemetry",
|
|
345
|
+
): Promise<Response> {
|
|
346
|
+
if (!reviewId || LOCAL_ROUTES.has(reviewId)) return input.local(request);
|
|
347
|
+
const owner = await ownerOf(reviewId);
|
|
348
|
+
|
|
349
|
+
if (owner === undefined || owner === "local") return input.local(request);
|
|
350
|
+
|
|
351
|
+
const alias = "remote" in owner ? owner.remote.alias : owner.down.alias;
|
|
352
|
+
|
|
353
|
+
if (route === "telemetry") {
|
|
354
|
+
await request.body?.cancel();
|
|
355
|
+
|
|
356
|
+
return answer(alias, 200, { ok: true });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!route)
|
|
360
|
+
return answer(alias, 404, {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: "This route is not available for a review on another machine.",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if ("down" in owner) return unavailable(owner.down);
|
|
366
|
+
|
|
367
|
+
return forward(owner.remote, request, { route });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const app = new Hono({ strict: false }).basePath("/reviews-api");
|
|
371
|
+
|
|
372
|
+
app.post("/commands", (context) => command(context.req.raw));
|
|
373
|
+
app.get("/", (context) => streams.list(context.req.raw));
|
|
374
|
+
app.get("/watch", (context) => streams.watch(context.req.raw));
|
|
375
|
+
app.all("/:id/telemetry/*", (context) =>
|
|
376
|
+
review(context.req.raw, context.req.param("id"), "telemetry"),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
for (const route of REMOTE_ROUTES)
|
|
380
|
+
app.on(route.method, `/:id${route.path && `/${route.path}`}`, (context) =>
|
|
381
|
+
review(context.req.raw, context.req.param("id") ?? "", route),
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
for (const path of ["/:id", "/:id/*"])
|
|
385
|
+
app.all(path, (context) =>
|
|
386
|
+
review(context.req.raw, context.req.param("id") ?? ""),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
app.all("*", (context) => input.local(context.req.raw));
|
|
390
|
+
|
|
391
|
+
const streams = createGatewayStreams({
|
|
392
|
+
hosts,
|
|
393
|
+
memory,
|
|
394
|
+
local: input.local,
|
|
395
|
+
lists,
|
|
396
|
+
locate,
|
|
397
|
+
listed() {
|
|
398
|
+
for (const check of waiting) check();
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
async function claim(remote: GatewayRemote, reviewId: string) {
|
|
403
|
+
if ((await ownerOf(reviewId)) === undefined)
|
|
404
|
+
return `${reviewId} is not on ${remote.alias}'s list.`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const pushes = createGatewayPushes({
|
|
408
|
+
hosts,
|
|
409
|
+
relay: input.relay,
|
|
410
|
+
claim,
|
|
411
|
+
log,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const stopWatchingWindows = input.relay.onAttachedChange(() => {
|
|
415
|
+
if (!closed) pushes.changed();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
fetch: (request: Request) => app.fetch(request),
|
|
420
|
+
setHosts: (list: ReviewGatewayHost[]) => hosts.set(list),
|
|
421
|
+
hosts: () => hosts.states(),
|
|
422
|
+
async close() {
|
|
423
|
+
closed = true;
|
|
424
|
+
stopWatchingWindows();
|
|
425
|
+
streams.close();
|
|
426
|
+
pushes.close();
|
|
427
|
+
hosts.close();
|
|
428
|
+
await memory.flush();
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export type ReviewGateway = ReturnType<typeof createReviewGateway>;
|
|
@@ -4,7 +4,12 @@ import type {
|
|
|
4
4
|
ReviewServerHealthWithToken,
|
|
5
5
|
} from "@dev.fast/review-protocol";
|
|
6
6
|
import { traceMachineEnabled } from "@dev.fast/trace-core";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type AskAgentLauncher,
|
|
9
|
+
askAgentTakesMcp,
|
|
10
|
+
detectAskAgents,
|
|
11
|
+
launchAskAgent,
|
|
12
|
+
} from "@review/ask/agents.js";
|
|
8
13
|
import { AskThreads, type AskTools } from "@review/ask/threads.js";
|
|
9
14
|
import {
|
|
10
15
|
readBuildCommit,
|
|
@@ -94,6 +99,12 @@ export function createReviewServerApp(input: {
|
|
|
94
99
|
|
|
95
100
|
return serverJson(accepted ? 200 : 404, { ok: accepted });
|
|
96
101
|
});
|
|
102
|
+
answerErrorsAsJson(app);
|
|
103
|
+
|
|
104
|
+
return app;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function answerErrorsAsJson(app: Hono<ReviewHonoEnv>) {
|
|
97
108
|
app.notFound(() => serverJson(404, { ok: false, error: "Not found." }));
|
|
98
109
|
app.onError((error) => {
|
|
99
110
|
const serverError = error instanceof ReviewServerError ? error : undefined;
|
|
@@ -107,8 +118,6 @@ export function createReviewServerApp(input: {
|
|
|
107
118
|
: { ok: false, error: message },
|
|
108
119
|
);
|
|
109
120
|
});
|
|
110
|
-
|
|
111
|
-
return app;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
export interface WhiteboardCoreInput {
|
|
@@ -124,7 +133,13 @@ export interface WhiteboardCoreInput {
|
|
|
124
133
|
scratchpad: () => boolean;
|
|
125
134
|
status: () => JsonObject;
|
|
126
135
|
hooks?: ReviewApiHooks;
|
|
127
|
-
ask?: {
|
|
136
|
+
ask?: {
|
|
137
|
+
tools: AskTools;
|
|
138
|
+
/** Tests only: an in-process agent. */
|
|
139
|
+
launch?: AskAgentLauncher;
|
|
140
|
+
/** Where agents are found, and the environment they run in. */
|
|
141
|
+
env?: Promise<NodeJS.ProcessEnv>;
|
|
142
|
+
};
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
export function createWhiteboardCore(input: WhiteboardCoreInput) {
|
|
@@ -139,8 +154,20 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
|
|
|
139
154
|
|
|
140
155
|
const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
|
|
141
156
|
|
|
157
|
+
const { ask } = input;
|
|
158
|
+
|
|
159
|
+
const launch = ask?.launch ?? launchAskAgent;
|
|
160
|
+
|
|
142
161
|
const askThreads =
|
|
143
|
-
|
|
162
|
+
ask &&
|
|
163
|
+
new AskThreads(
|
|
164
|
+
async (agent, cwd, options) =>
|
|
165
|
+
launch(agent, cwd, { ...options, env: await ask.env }),
|
|
166
|
+
{
|
|
167
|
+
takesMcp: async (agent) => askAgentTakesMcp(agent, await ask.env),
|
|
168
|
+
...ask.tools,
|
|
169
|
+
},
|
|
170
|
+
);
|
|
144
171
|
|
|
145
172
|
const closeDeletedThreads = () =>
|
|
146
173
|
askThreads?.closeDeletedReviews((id) =>
|
|
@@ -167,7 +194,10 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
|
|
|
167
194
|
() => traceMachineEnabled(),
|
|
168
195
|
input.status,
|
|
169
196
|
input.hooks,
|
|
170
|
-
askThreads && {
|
|
197
|
+
askThreads && {
|
|
198
|
+
threads: askThreads,
|
|
199
|
+
agents: async () => detectAskAgents(await ask?.env),
|
|
200
|
+
},
|
|
171
201
|
);
|
|
172
202
|
|
|
173
203
|
// A shared store mounts the publisher with the rest of sharing.
|