@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,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type REVIEW_REMOTE_INSTALL_STEPS,
|
|
3
|
+
type ReviewGatewayHost,
|
|
4
|
+
type ReviewGatewayHostState,
|
|
5
|
+
} from "@dev.fast/review-protocol";
|
|
6
|
+
import { Agent } from "undici";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
FIRST_RETRY_MS,
|
|
11
|
+
MAX_RETRY_MS,
|
|
12
|
+
errorText,
|
|
13
|
+
jitter,
|
|
14
|
+
readBody,
|
|
15
|
+
send,
|
|
16
|
+
} from "./review-gateway-transport.js";
|
|
17
|
+
|
|
18
|
+
const HEALTH_TIMEOUT_MS = 3_000;
|
|
19
|
+
|
|
20
|
+
const HEARTBEAT_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
const INSTALLS = new Set<ReviewGatewayHostState["state"]>([
|
|
23
|
+
"incompatible",
|
|
24
|
+
"not-installed",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const INSTALL_STEPS: Record<
|
|
28
|
+
(typeof REVIEW_REMOTE_INSTALL_STEPS)[number],
|
|
29
|
+
string
|
|
30
|
+
> = {
|
|
31
|
+
preparing: "Preparing to install Whiteboard",
|
|
32
|
+
"waiting-for-lock": "Waiting for another install to finish",
|
|
33
|
+
package: "Installing the Whiteboard package",
|
|
34
|
+
done: "Installed; starting the server",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const healthSchema = z.object({
|
|
38
|
+
ok: z.literal(true),
|
|
39
|
+
serverId: z.string().optional(),
|
|
40
|
+
version: z.string(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export interface GatewayRemote {
|
|
44
|
+
readonly alias: string;
|
|
45
|
+
readonly endpoint?: { url: string; token: string };
|
|
46
|
+
readonly dispatcher?: Agent;
|
|
47
|
+
readonly serverId?: string;
|
|
48
|
+
readonly unauthorized?: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Host extends GatewayRemote {
|
|
52
|
+
endpoint?: { url: string; token: string };
|
|
53
|
+
dispatcher?: Agent;
|
|
54
|
+
unauthorized?: () => void;
|
|
55
|
+
problem?: ReviewGatewayHost["problem"];
|
|
56
|
+
languageFeatures?: boolean;
|
|
57
|
+
languageFeaturesDetail?: string;
|
|
58
|
+
languageGroups?: ReviewGatewayHost["languageGroups"];
|
|
59
|
+
installing?: ReviewGatewayHost["installing"];
|
|
60
|
+
declined?: true;
|
|
61
|
+
asking?: string;
|
|
62
|
+
installFailure?: string;
|
|
63
|
+
target?: ReviewGatewayHost["target"];
|
|
64
|
+
serverId?: string;
|
|
65
|
+
status: ReviewGatewayHostState["state"];
|
|
66
|
+
detail?: string;
|
|
67
|
+
retryMs: number;
|
|
68
|
+
retry?: NodeJS.Timeout;
|
|
69
|
+
checking?: AbortController;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createGatewayHosts(input: {
|
|
73
|
+
version: string;
|
|
74
|
+
log?(message: string): void;
|
|
75
|
+
machine?(serverId: string, alias: string): void;
|
|
76
|
+
changed?(): void;
|
|
77
|
+
restarted?(alias: string): void;
|
|
78
|
+
heartbeatMs?: number;
|
|
79
|
+
}) {
|
|
80
|
+
const log = input.log ?? (() => {});
|
|
81
|
+
let hosts: Host[] = [];
|
|
82
|
+
let closed = false;
|
|
83
|
+
const reported = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
const sharing = (host: Host) =>
|
|
86
|
+
host.serverId === undefined
|
|
87
|
+
? [host]
|
|
88
|
+
: hosts.filter((other) => other.serverId === host.serverId);
|
|
89
|
+
|
|
90
|
+
const serving = (serverId: string | undefined) =>
|
|
91
|
+
serverId === undefined
|
|
92
|
+
? undefined
|
|
93
|
+
: hosts.find(
|
|
94
|
+
(host) =>
|
|
95
|
+
host.status === "online" &&
|
|
96
|
+
host.serverId === serverId &&
|
|
97
|
+
sharing(host).length === 1,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
function stateOf(host: Host): ReviewGatewayHostState {
|
|
101
|
+
const known = {
|
|
102
|
+
alias: host.alias,
|
|
103
|
+
...(host.serverId !== undefined && { serverId: host.serverId }),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const aliases = sharing(host).map((other) => other.alias);
|
|
107
|
+
|
|
108
|
+
if (aliases.length > 1)
|
|
109
|
+
return {
|
|
110
|
+
...known,
|
|
111
|
+
state: "duplicate",
|
|
112
|
+
detail: `${aliases.join(", ")} report the same server id, so Whiteboard uses none of them. If they are one machine, keep one alias. If they are separate machines, run \`whiteboard server stop\` and then \`whiteboard server reset-id\` on all but one.`,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const detail =
|
|
116
|
+
host.status === "incompatible" && host.installFailure
|
|
117
|
+
? `${host.detail ? `${host.detail} ` : ""}${host.installFailure}`
|
|
118
|
+
: host.detail;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...known,
|
|
122
|
+
state: host.status,
|
|
123
|
+
...(detail !== undefined && { detail }),
|
|
124
|
+
...(INSTALLS.has(host.status) &&
|
|
125
|
+
host.problem?.state !== "incompatible" && {
|
|
126
|
+
installCommand: [
|
|
127
|
+
`mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${input.version}/remote/whiteboard-remote-${input.version}-${host.target ?? "linux-x64"}.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote`,
|
|
128
|
+
"mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard",
|
|
129
|
+
].join("\n"),
|
|
130
|
+
}),
|
|
131
|
+
...(host.declined &&
|
|
132
|
+
host.status !== "online" &&
|
|
133
|
+
host.status !== "connecting" && { declined: true as const }),
|
|
134
|
+
...(host.status === "online" && languageOf(host)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function report() {
|
|
139
|
+
const states = hosts.map(stateOf);
|
|
140
|
+
|
|
141
|
+
for (const host of hosts)
|
|
142
|
+
if (host.serverId !== undefined && serving(host.serverId) === host)
|
|
143
|
+
input.machine?.(host.serverId, host.alias);
|
|
144
|
+
|
|
145
|
+
for (const state of states) {
|
|
146
|
+
const line = `${state.state}${state.detail ? ` (${state.detail})` : ""}`;
|
|
147
|
+
|
|
148
|
+
if (reported.get(state.alias) === line) continue;
|
|
149
|
+
reported.set(state.alias, line);
|
|
150
|
+
log(`Host ${state.alias}: ${line}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const alias of reported.keys())
|
|
154
|
+
if (!states.some((state) => state.alias === alias))
|
|
155
|
+
reported.delete(alias);
|
|
156
|
+
|
|
157
|
+
input.changed?.();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restartedHost(
|
|
161
|
+
host: Host,
|
|
162
|
+
detail = `${host.alias} restarted; attaching again.`,
|
|
163
|
+
) {
|
|
164
|
+
if (closed || !host.endpoint) return;
|
|
165
|
+
host.endpoint = undefined;
|
|
166
|
+
clearTimeout(host.retry);
|
|
167
|
+
host.checking?.abort();
|
|
168
|
+
host.checking = undefined;
|
|
169
|
+
host.status = "offline";
|
|
170
|
+
host.detail = detail;
|
|
171
|
+
report();
|
|
172
|
+
input.restarted?.(host.alias);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function dispose(host: Host) {
|
|
176
|
+
clearTimeout(host.retry);
|
|
177
|
+
host.checking?.abort();
|
|
178
|
+
host.checking = undefined;
|
|
179
|
+
void host.dispatcher?.destroy();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function create(given: ReviewGatewayHost): Host {
|
|
183
|
+
const host: Host = {
|
|
184
|
+
alias: given.alias,
|
|
185
|
+
status: "connecting",
|
|
186
|
+
retryMs: FIRST_RETRY_MS,
|
|
187
|
+
...languageOf(given),
|
|
188
|
+
...(given.target && { target: given.target }),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (given.endpoint) host.endpoint = given.endpoint;
|
|
192
|
+
|
|
193
|
+
if (given.declined) host.declined = true;
|
|
194
|
+
|
|
195
|
+
if (given.installFailure) host.installFailure = given.installFailure;
|
|
196
|
+
|
|
197
|
+
if (given.problem) {
|
|
198
|
+
host.problem = given.problem;
|
|
199
|
+
host.status = given.problem.state;
|
|
200
|
+
host.detail = given.problem.detail;
|
|
201
|
+
} else if (given.installing && !given.endpoint) {
|
|
202
|
+
host.installing = given.installing;
|
|
203
|
+
host.status = "installing";
|
|
204
|
+
host.detail = `${INSTALL_STEPS[given.installing.step]}.`;
|
|
205
|
+
} else if (given.asking && !given.endpoint) {
|
|
206
|
+
host.asking = given.asking;
|
|
207
|
+
host.detail = `Waiting for an answer: install Whiteboard ${given.asking} on ${given.alias}?`;
|
|
208
|
+
} else if (!given.endpoint)
|
|
209
|
+
host.detail = `Waiting for a connection to ${given.alias}.`;
|
|
210
|
+
else {
|
|
211
|
+
host.dispatcher = new Agent();
|
|
212
|
+
host.unauthorized = () => restartedHost(host);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return host;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function schedule(host: Host, ms: number) {
|
|
219
|
+
host.retry = setTimeout(() => void check(host), ms);
|
|
220
|
+
host.retry.unref();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function retryLater(host: Host) {
|
|
224
|
+
schedule(host, jitter(host.retryMs));
|
|
225
|
+
host.retryMs = Math.min(host.retryMs * 2, MAX_RETRY_MS);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function check(host: Host) {
|
|
229
|
+
if (closed || !host.endpoint || host.problem) return;
|
|
230
|
+
clearTimeout(host.retry);
|
|
231
|
+
host.checking?.abort();
|
|
232
|
+
const abort = new AbortController();
|
|
233
|
+
host.checking = abort;
|
|
234
|
+
const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
|
|
235
|
+
let health: z.infer<typeof healthSchema> | undefined;
|
|
236
|
+
let reason = "it did not answer";
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const response = await send(host, {
|
|
240
|
+
method: "GET",
|
|
241
|
+
path: "/health",
|
|
242
|
+
headers: { "x-review-token": host.endpoint.token },
|
|
243
|
+
signal: AbortSignal.any([abort.signal, timeout]),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const parsed = healthSchema.safeParse(
|
|
247
|
+
JSON.parse((await readBody(response.body, 64 * 1024)).toString()),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (parsed.success) health = parsed.data;
|
|
251
|
+
else reason = "it did not answer as a Whiteboard server";
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (timeout.aborted)
|
|
254
|
+
reason = `it did not answer within ${HEALTH_TIMEOUT_MS / 1_000} seconds`;
|
|
255
|
+
else if (!abort.signal.aborted) reason = errorText(error);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (host.checking !== abort) return;
|
|
259
|
+
host.checking = undefined;
|
|
260
|
+
|
|
261
|
+
// /health leaves out serverId when it refuses the token, as after a restart.
|
|
262
|
+
if (health && health.serverId === undefined) return restartedHost(host);
|
|
263
|
+
|
|
264
|
+
if (!health && host.serverId !== undefined)
|
|
265
|
+
return restartedHost(
|
|
266
|
+
host,
|
|
267
|
+
`${host.alias} is offline: ${reason}; attaching again.`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (!health) {
|
|
271
|
+
host.status = "offline";
|
|
272
|
+
host.detail = `${host.alias} is offline: ${reason}.`;
|
|
273
|
+
retryLater(host);
|
|
274
|
+
} else {
|
|
275
|
+
host.serverId = health.serverId;
|
|
276
|
+
host.retryMs = FIRST_RETRY_MS;
|
|
277
|
+
|
|
278
|
+
if (health.version === "unknown" || health.version !== input.version) {
|
|
279
|
+
host.status = "incompatible";
|
|
280
|
+
host.detail = `${host.alias} runs Whiteboard ${health.version}; this Desktop runs ${input.version}. Install Whiteboard ${input.version} on ${host.alias}.`;
|
|
281
|
+
} else {
|
|
282
|
+
host.status = "online";
|
|
283
|
+
host.detail = undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
schedule(host, input.heartbeatMs ?? HEARTBEAT_MS);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
report();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
set(list: ReviewGatewayHost[]) {
|
|
294
|
+
const previous = new Map(hosts.map((host) => [host.alias, host]));
|
|
295
|
+
const next: Host[] = [];
|
|
296
|
+
const changed: Host[] = [];
|
|
297
|
+
|
|
298
|
+
for (const given of list) {
|
|
299
|
+
if (next.some((host) => host.alias === given.alias)) continue;
|
|
300
|
+
const current = previous.get(given.alias);
|
|
301
|
+
|
|
302
|
+
if (
|
|
303
|
+
current &&
|
|
304
|
+
JSON.stringify([
|
|
305
|
+
current.endpoint,
|
|
306
|
+
current.problem,
|
|
307
|
+
current.installing,
|
|
308
|
+
current.declined,
|
|
309
|
+
current.asking,
|
|
310
|
+
current.installFailure,
|
|
311
|
+
]) ===
|
|
312
|
+
JSON.stringify([
|
|
313
|
+
given.endpoint,
|
|
314
|
+
given.problem,
|
|
315
|
+
given.installing,
|
|
316
|
+
given.declined,
|
|
317
|
+
given.asking,
|
|
318
|
+
given.installFailure,
|
|
319
|
+
])
|
|
320
|
+
) {
|
|
321
|
+
previous.delete(given.alias);
|
|
322
|
+
current.languageFeatures = given.languageFeatures;
|
|
323
|
+
current.languageFeaturesDetail = given.languageFeaturesDetail;
|
|
324
|
+
current.languageGroups = given.languageGroups;
|
|
325
|
+
current.target = given.target;
|
|
326
|
+
next.push(current);
|
|
327
|
+
} else {
|
|
328
|
+
const host = create(given);
|
|
329
|
+
next.push(host);
|
|
330
|
+
changed.push(host);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const gone of previous.values()) dispose(gone);
|
|
335
|
+
hosts = next;
|
|
336
|
+
|
|
337
|
+
for (const host of changed) void check(host);
|
|
338
|
+
report();
|
|
339
|
+
},
|
|
340
|
+
states: () => hosts.map(stateOf),
|
|
341
|
+
serving: (serverId: string): GatewayRemote | undefined => serving(serverId),
|
|
342
|
+
online: (): GatewayRemote[] =>
|
|
343
|
+
hosts.filter((host) => serving(host.serverId) === host),
|
|
344
|
+
failed(remote: GatewayRemote, reason: string) {
|
|
345
|
+
const host = hosts.find((candidate) => candidate === remote);
|
|
346
|
+
|
|
347
|
+
if (!host || host.status !== "online") return;
|
|
348
|
+
host.status = "offline";
|
|
349
|
+
host.detail = `${host.alias} is offline: ${reason}.`;
|
|
350
|
+
report();
|
|
351
|
+
void check(host);
|
|
352
|
+
},
|
|
353
|
+
recheck(remote: GatewayRemote) {
|
|
354
|
+
const host = hosts.find((candidate) => candidate === remote);
|
|
355
|
+
|
|
356
|
+
if (host && !host.checking) void check(host);
|
|
357
|
+
},
|
|
358
|
+
close() {
|
|
359
|
+
closed = true;
|
|
360
|
+
|
|
361
|
+
for (const host of hosts) dispose(host);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type GatewayHosts = ReturnType<typeof createGatewayHosts>;
|
|
367
|
+
|
|
368
|
+
const languageOf = ({
|
|
369
|
+
languageFeatures,
|
|
370
|
+
languageFeaturesDetail,
|
|
371
|
+
languageGroups,
|
|
372
|
+
}: Pick<
|
|
373
|
+
ReviewGatewayHost,
|
|
374
|
+
"languageFeatures" | "languageFeaturesDetail" | "languageGroups"
|
|
375
|
+
>) => ({
|
|
376
|
+
...(languageFeatures !== undefined && { languageFeatures }),
|
|
377
|
+
...(languageFeaturesDetail !== undefined && { languageFeaturesDetail }),
|
|
378
|
+
...(languageGroups !== undefined && { languageGroups }),
|
|
379
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReviewApiSummary,
|
|
3
|
+
type ReviewGatewayHostState,
|
|
4
|
+
reviewHostStatus,
|
|
5
|
+
} from "@dev.fast/review-protocol";
|
|
6
|
+
|
|
7
|
+
import { LIST_MODES, type ListMode } from "./review-gateway-memory.js";
|
|
8
|
+
|
|
9
|
+
export interface ListSource {
|
|
10
|
+
states: readonly ReviewGatewayHostState[];
|
|
11
|
+
serving(serverId: string): boolean;
|
|
12
|
+
serverIdOf(alias: string): string | undefined;
|
|
13
|
+
list(serverId: string, mode: ListMode): ReviewApiSummary[] | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function decorate(
|
|
17
|
+
entry: ReviewApiSummary,
|
|
18
|
+
state: ReviewGatewayHostState,
|
|
19
|
+
hostState: NonNullable<ReviewApiSummary["hostState"]>,
|
|
20
|
+
): ReviewApiSummary {
|
|
21
|
+
const { alias } = state;
|
|
22
|
+
const online = hostState === "online";
|
|
23
|
+
const features = online && state.languageFeatures === true;
|
|
24
|
+
|
|
25
|
+
const reason = online
|
|
26
|
+
? features
|
|
27
|
+
? undefined
|
|
28
|
+
: state.languageFeaturesDetail
|
|
29
|
+
: reviewHostStatus({
|
|
30
|
+
alias,
|
|
31
|
+
state: hostState,
|
|
32
|
+
detail: hostState === state.state ? state.detail : undefined,
|
|
33
|
+
}).sentence;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...entry,
|
|
37
|
+
...(entry.repositoryGroup && {
|
|
38
|
+
repositoryGroup: {
|
|
39
|
+
key: `${alias}:${entry.repositoryGroup.key}`,
|
|
40
|
+
label: entry.repositoryGroup.label,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
host: alias,
|
|
44
|
+
hostState,
|
|
45
|
+
available: {
|
|
46
|
+
sourceWindows: features,
|
|
47
|
+
...(reason && { reason }),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hostStateOf = (
|
|
53
|
+
state: ReviewGatewayHostState,
|
|
54
|
+
serving: boolean,
|
|
55
|
+
): NonNullable<ReviewApiSummary["hostState"]> => {
|
|
56
|
+
if (serving) return "online";
|
|
57
|
+
|
|
58
|
+
return state.state === "online" || state.state === "duplicate"
|
|
59
|
+
? "offline"
|
|
60
|
+
: state.state;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Settings order, one entry per server: the first to list an id owns it.
|
|
64
|
+
function* servers(source: ListSource) {
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
|
|
67
|
+
for (const state of source.states) {
|
|
68
|
+
const serverId = state.serverId ?? source.serverIdOf(state.alias);
|
|
69
|
+
|
|
70
|
+
if (serverId === undefined || seen.has(serverId)) continue;
|
|
71
|
+
seen.add(serverId);
|
|
72
|
+
yield { state, serverId };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listedBy(reviewId: string, source: ListSource) {
|
|
77
|
+
for (const server of servers(source))
|
|
78
|
+
if (
|
|
79
|
+
LIST_MODES.some((mode) =>
|
|
80
|
+
source
|
|
81
|
+
.list(server.serverId, mode)
|
|
82
|
+
?.some((entry) => entry.reviewId === reviewId),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return server;
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function mergeLists(
|
|
91
|
+
mode: ListMode,
|
|
92
|
+
local: readonly ReviewApiSummary[],
|
|
93
|
+
source: ListSource,
|
|
94
|
+
) {
|
|
95
|
+
const ids = new Set(local.map((entry) => entry.reviewId));
|
|
96
|
+
const merged = [...local];
|
|
97
|
+
|
|
98
|
+
for (const { state, serverId } of servers(source)) {
|
|
99
|
+
const hostState = hostStateOf(state, source.serving(serverId));
|
|
100
|
+
|
|
101
|
+
for (const entry of source.list(serverId, mode) ?? [])
|
|
102
|
+
if (!ids.has(entry.reviewId)) {
|
|
103
|
+
ids.add(entry.reviewId);
|
|
104
|
+
merged.push(decorate(entry, state, hostState));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ReviewApiSummary } from "@dev.fast/review-protocol";
|
|
5
|
+
import { errorMessage, writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export const LIST_MODES = ["structural", "textual"] as const;
|
|
9
|
+
|
|
10
|
+
export type ListMode = (typeof LIST_MODES)[number];
|
|
11
|
+
|
|
12
|
+
export const UUID =
|
|
13
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
|
|
15
|
+
const listEntrySchema = z.looseObject({
|
|
16
|
+
reviewId: z.string().regex(UUID),
|
|
17
|
+
version: z.number(),
|
|
18
|
+
title: z.string(),
|
|
19
|
+
createdAt: z.string(),
|
|
20
|
+
repositoryName: z.string(),
|
|
21
|
+
viewedAt: z.string().nullable(),
|
|
22
|
+
trashedAt: z.string().nullable(),
|
|
23
|
+
repositoryGroup: z.object({ key: z.string(), label: z.string() }).optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const listEntriesSchema = z.array(z.unknown()).transform(
|
|
27
|
+
(entries) =>
|
|
28
|
+
// SAFETY: the schema checks the fields the gateway and Home rely on.
|
|
29
|
+
entries.filter(
|
|
30
|
+
(entry) => listEntrySchema.safeParse(entry).success,
|
|
31
|
+
) as ReviewApiSummary[],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const lastListSchema = z.object({
|
|
35
|
+
structural: listEntriesSchema.optional(),
|
|
36
|
+
textual: listEntriesSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const memorySchema = z.record(
|
|
40
|
+
z.string(),
|
|
41
|
+
z.object({ alias: z.string(), lastList: lastListSchema.optional() }),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
interface Server {
|
|
45
|
+
alias: string;
|
|
46
|
+
lastList: Partial<Record<ListMode, ReviewApiSummary[]>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function gatewayMemoryPath(home: string) {
|
|
50
|
+
return path.join(home, "remotes.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function openGatewayMemory(
|
|
54
|
+
home: string,
|
|
55
|
+
log: (message: string) => void = () => {},
|
|
56
|
+
) {
|
|
57
|
+
const file = gatewayMemoryPath(home);
|
|
58
|
+
const servers = new Map<string, Server>();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const saved = memorySchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
62
|
+
|
|
63
|
+
for (const [serverId, { alias, lastList }] of Object.entries(saved))
|
|
64
|
+
servers.set(serverId, { alias, lastList: lastList ?? {} });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// SAFETY: fs rejects with a Node ErrnoException carrying `code`.
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
68
|
+
log(`Ignoring unreadable remote review memory ${file}: ${String(error)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let writing = Promise.resolve();
|
|
72
|
+
let queued = false;
|
|
73
|
+
|
|
74
|
+
const save = () => {
|
|
75
|
+
if (queued) return;
|
|
76
|
+
queued = true;
|
|
77
|
+
|
|
78
|
+
writing = writing
|
|
79
|
+
.then(() => {
|
|
80
|
+
queued = false;
|
|
81
|
+
|
|
82
|
+
return writePrivateJsonAtomic(file, Object.fromEntries(servers));
|
|
83
|
+
})
|
|
84
|
+
.catch((cause: unknown) =>
|
|
85
|
+
log(`Could not save remote review memory: ${errorMessage(cause)}`),
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
serverIdOf(alias: string) {
|
|
91
|
+
for (const [serverId, server] of servers)
|
|
92
|
+
if (server.alias === alias) return serverId;
|
|
93
|
+
|
|
94
|
+
return undefined;
|
|
95
|
+
},
|
|
96
|
+
list: (serverId: string, mode: ListMode) =>
|
|
97
|
+
servers.get(serverId)?.lastList[mode],
|
|
98
|
+
setList(
|
|
99
|
+
serverId: string,
|
|
100
|
+
alias: string,
|
|
101
|
+
mode: ListMode,
|
|
102
|
+
reviews: ReviewApiSummary[],
|
|
103
|
+
) {
|
|
104
|
+
let server = servers.get(serverId);
|
|
105
|
+
|
|
106
|
+
if (!server) {
|
|
107
|
+
server = { alias, lastList: {} };
|
|
108
|
+
servers.set(serverId, server);
|
|
109
|
+
} else if (
|
|
110
|
+
server.alias === alias &&
|
|
111
|
+
JSON.stringify(server.lastList[mode]) === JSON.stringify(reviews)
|
|
112
|
+
)
|
|
113
|
+
return;
|
|
114
|
+
|
|
115
|
+
server.alias = alias;
|
|
116
|
+
server.lastList[mode] = reviews;
|
|
117
|
+
save();
|
|
118
|
+
},
|
|
119
|
+
rename(serverId: string, alias: string) {
|
|
120
|
+
const server = servers.get(serverId);
|
|
121
|
+
|
|
122
|
+
if (!server || server.alias === alias) return;
|
|
123
|
+
server.alias = alias;
|
|
124
|
+
save();
|
|
125
|
+
},
|
|
126
|
+
flush: () => writing,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type GatewayMemory = ReturnType<typeof openGatewayMemory>;
|