@xfey/tutti 0.1.29 → 0.1.30
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/README.md +1 -1
- package/dist/server-shell/cli/args.d.ts +9 -0
- package/dist/server-shell/cli/args.js +55 -5
- package/dist/server-shell/cli/cli.js +46 -7
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/errors.js +6 -1
- package/dist/server-shell/cli/host-lifecycle.d.ts +1 -0
- package/dist/server-shell/cli/host-lifecycle.js +155 -41
- package/dist/server-shell/cli/host-relay-connection-manager.d.ts +50 -0
- package/dist/server-shell/cli/host-relay-connection-manager.js +447 -0
- package/dist/server-shell/cli/host-relay-status.d.ts +4 -0
- package/dist/server-shell/cli/host-relay-status.js +74 -15
- package/dist/server-shell/cli/host-runtime-endpoint.js +26 -0
- package/dist/server-shell/cli/host-server-runtime.d.ts +2 -2
- package/dist/server-shell/cli/host-server-runtime.js +19 -15
- package/dist/server-shell/cli/launch-command.d.ts +1 -0
- package/dist/server-shell/cli/launch-command.js +8 -10
- package/dist/server-shell/cli/launch.d.ts +1 -0
- package/dist/server-shell/cli/launch.js +7 -3
- package/dist/server-shell/cli/local-control-client.d.ts +5 -0
- package/dist/server-shell/cli/local-control-client.js +9 -0
- package/dist/server-shell/cli/machine-local.d.ts +8 -0
- package/dist/server-shell/cli/machine-local.js +29 -0
- package/dist/server-shell/cli/machine-project-inspector.d.ts +81 -0
- package/dist/server-shell/cli/machine-project-inspector.js +205 -0
- package/dist/server-shell/cli/project-resolver.js +22 -32
- package/dist/server-shell/cli/relay-registration.d.ts +5 -2
- package/dist/server-shell/cli/relay-registration.js +7 -3
- package/dist/server-shell/cli/runtime-commands.d.ts +30 -4
- package/dist/server-shell/cli/runtime-commands.js +230 -113
- package/dist/server-shell/http/routes/local-control.d.ts +10 -0
- package/dist/server-shell/http/routes/local-control.js +29 -0
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +23 -0
- package/node_modules/@tutti/relay-client/dist/host-control.js +70 -3
- package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +6 -2
- package/node_modules/@tutti/relay-client/dist/tunnel.js +13 -2
- package/package.json +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { RelayHostControlClientError } from "@tutti/relay-client";
|
|
2
|
+
import { LaunchError } from "./errors.js";
|
|
3
|
+
import { inspectMachineProjectInventory, normalizeMachineWorkspacePath, } from "./machine-project-inspector.js";
|
|
4
|
+
class WorkspaceUnavailableError extends Error {
|
|
5
|
+
workspaceState;
|
|
6
|
+
constructor(workspaceState) {
|
|
7
|
+
super(`Workspace is ${workspaceState}`);
|
|
8
|
+
this.workspaceState = workspaceState;
|
|
9
|
+
this.name = "WorkspaceUnavailableError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_RETRY_DELAYS_MS = [0, 1_000, 2_000, 5_000, 10_000, 30_000];
|
|
13
|
+
const DEFAULT_JITTER_RATIO = 0.2;
|
|
14
|
+
const DEFAULT_WORKSPACE_CHECK_INTERVAL_MS = 30_000;
|
|
15
|
+
function mergeKnownJoinUrl(previous, next) {
|
|
16
|
+
if (next.join_url !== undefined ||
|
|
17
|
+
previous?.join_url === undefined ||
|
|
18
|
+
previous.join_token_expires_at !== next.join_token_expires_at) {
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
...next,
|
|
23
|
+
join_url: previous.join_url,
|
|
24
|
+
join_url_visibility: previous.join_url_visibility,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function retryableConnectionError(error) {
|
|
28
|
+
if (error instanceof RelayHostControlClientError) {
|
|
29
|
+
return error.retryable;
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof LaunchError) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
function connectionErrorCode(error) {
|
|
37
|
+
if (error instanceof RelayHostControlClientError) {
|
|
38
|
+
return error.relayErrorCode ?? error.code;
|
|
39
|
+
}
|
|
40
|
+
if (error instanceof LaunchError) {
|
|
41
|
+
return error.code;
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof Error && error.name.trim() !== "") {
|
|
44
|
+
return error.name.slice(0, 80);
|
|
45
|
+
}
|
|
46
|
+
return "connection_failed";
|
|
47
|
+
}
|
|
48
|
+
class DefaultHostRelayConnectionManager {
|
|
49
|
+
options;
|
|
50
|
+
state = {
|
|
51
|
+
status: "unavailable",
|
|
52
|
+
reason_code: "not_started",
|
|
53
|
+
};
|
|
54
|
+
retryDelaysMs;
|
|
55
|
+
jitterRatio;
|
|
56
|
+
workspaceCheckIntervalMs;
|
|
57
|
+
now;
|
|
58
|
+
random;
|
|
59
|
+
normalizedWorkspaceRoot;
|
|
60
|
+
scheduledTimer;
|
|
61
|
+
activeTunnel;
|
|
62
|
+
pendingTunnel;
|
|
63
|
+
attemptController;
|
|
64
|
+
attemptPromise;
|
|
65
|
+
startPromise;
|
|
66
|
+
reconnectPromise;
|
|
67
|
+
refreshPromise;
|
|
68
|
+
lastConnection;
|
|
69
|
+
reconnectFailureCount = 0;
|
|
70
|
+
stopping = false;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
this.retryDelaysMs =
|
|
74
|
+
options.retryDelaysMs === undefined || options.retryDelaysMs.length === 0
|
|
75
|
+
? DEFAULT_RETRY_DELAYS_MS
|
|
76
|
+
: options.retryDelaysMs;
|
|
77
|
+
this.jitterRatio = Math.max(0, Math.min(options.jitterRatio ?? DEFAULT_JITTER_RATIO, 1));
|
|
78
|
+
this.workspaceCheckIntervalMs =
|
|
79
|
+
options.workspaceCheckIntervalMs ?? DEFAULT_WORKSPACE_CHECK_INTERVAL_MS;
|
|
80
|
+
this.now = options.now ?? (() => new Date());
|
|
81
|
+
this.random = options.random ?? Math.random;
|
|
82
|
+
this.normalizedWorkspaceRoot = normalizeMachineWorkspacePath(options.workspaceRoot);
|
|
83
|
+
}
|
|
84
|
+
getState() {
|
|
85
|
+
return this.state;
|
|
86
|
+
}
|
|
87
|
+
async start() {
|
|
88
|
+
if (this.state.status === "connected") {
|
|
89
|
+
return this.state.connection;
|
|
90
|
+
}
|
|
91
|
+
if (this.stopping || this.state.status === "stopped") {
|
|
92
|
+
throw new LaunchError("relay_registration_failed", "Relay connection manager is stopped", "Start a new Tutti host process before reconnecting.");
|
|
93
|
+
}
|
|
94
|
+
if (this.startPromise !== undefined) {
|
|
95
|
+
return await this.startPromise;
|
|
96
|
+
}
|
|
97
|
+
const promise = (async () => {
|
|
98
|
+
try {
|
|
99
|
+
return await this.connectOnce("rotate", true);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
this.handleInitialFailure(error);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
this.startPromise = promise;
|
|
107
|
+
void promise.then(() => {
|
|
108
|
+
if (this.startPromise === promise) {
|
|
109
|
+
this.startPromise = undefined;
|
|
110
|
+
}
|
|
111
|
+
}, () => {
|
|
112
|
+
if (this.startPromise === promise) {
|
|
113
|
+
this.startPromise = undefined;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return await promise;
|
|
117
|
+
}
|
|
118
|
+
async reconnectNow() {
|
|
119
|
+
if (this.state.status === "connected" || this.state.status === "stopped") {
|
|
120
|
+
return this.state;
|
|
121
|
+
}
|
|
122
|
+
this.clearScheduledTimer();
|
|
123
|
+
await this.beginReconnect();
|
|
124
|
+
return this.state;
|
|
125
|
+
}
|
|
126
|
+
async refreshJoinUrl() {
|
|
127
|
+
if (this.refreshPromise !== undefined) {
|
|
128
|
+
return await this.refreshPromise;
|
|
129
|
+
}
|
|
130
|
+
if (this.options.refreshJoinUrl === undefined ||
|
|
131
|
+
this.state.status !== "connected" ||
|
|
132
|
+
this.activeTunnel === undefined) {
|
|
133
|
+
throw new LaunchError("relay_registration_failed", "Relay is not connected, so the join URL cannot be refreshed", "Wait for Relay reconnection, then retry `tutti invite`.");
|
|
134
|
+
}
|
|
135
|
+
const connection = this.state.connection;
|
|
136
|
+
const promise = (async () => {
|
|
137
|
+
const refreshed = await this.options.refreshJoinUrl?.(connection);
|
|
138
|
+
if (refreshed === undefined) {
|
|
139
|
+
throw new LaunchError("relay_registration_failed", "Relay join URL refresh is unavailable", "Retry after the Relay connection is restored.");
|
|
140
|
+
}
|
|
141
|
+
if (this.state.status === "connected" &&
|
|
142
|
+
this.state.connection.host_connection_ref === connection.host_connection_ref) {
|
|
143
|
+
this.lastConnection = refreshed;
|
|
144
|
+
this.setState({ status: "connected", connection: refreshed });
|
|
145
|
+
}
|
|
146
|
+
return refreshed;
|
|
147
|
+
})();
|
|
148
|
+
this.refreshPromise = promise;
|
|
149
|
+
void promise.then(() => {
|
|
150
|
+
if (this.refreshPromise === promise) {
|
|
151
|
+
this.refreshPromise = undefined;
|
|
152
|
+
}
|
|
153
|
+
}, () => {
|
|
154
|
+
if (this.refreshPromise === promise) {
|
|
155
|
+
this.refreshPromise = undefined;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return await promise;
|
|
159
|
+
}
|
|
160
|
+
async stop() {
|
|
161
|
+
if (this.state.status === "stopped") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.stopping = true;
|
|
165
|
+
this.clearScheduledTimer();
|
|
166
|
+
this.attemptController?.abort();
|
|
167
|
+
const pendingTunnel = this.pendingTunnel;
|
|
168
|
+
const activeTunnel = this.activeTunnel;
|
|
169
|
+
this.pendingTunnel = undefined;
|
|
170
|
+
this.activeTunnel = undefined;
|
|
171
|
+
pendingTunnel?.close(1000, "host stopping");
|
|
172
|
+
if (activeTunnel !== pendingTunnel) {
|
|
173
|
+
activeTunnel?.close(1000, "host stopping");
|
|
174
|
+
}
|
|
175
|
+
this.setState({ status: "stopped" });
|
|
176
|
+
const pending = [];
|
|
177
|
+
if (this.startPromise !== undefined) {
|
|
178
|
+
pending.push(this.startPromise);
|
|
179
|
+
}
|
|
180
|
+
if (this.reconnectPromise !== undefined) {
|
|
181
|
+
pending.push(this.reconnectPromise);
|
|
182
|
+
}
|
|
183
|
+
if (this.attemptPromise !== undefined) {
|
|
184
|
+
pending.push(this.attemptPromise);
|
|
185
|
+
}
|
|
186
|
+
await Promise.allSettled(pending);
|
|
187
|
+
}
|
|
188
|
+
readWorkspaceAvailability() {
|
|
189
|
+
const inspection = inspectMachineProjectInventory(this.options.projectId, this.options.tuttiHome);
|
|
190
|
+
if (inspection.workspace.kind === "missing") {
|
|
191
|
+
return "missing";
|
|
192
|
+
}
|
|
193
|
+
if (inspection.normalized_workspace_root !== this.normalizedWorkspaceRoot) {
|
|
194
|
+
return "replaced";
|
|
195
|
+
}
|
|
196
|
+
if (inspection.workspace.kind === "matching") {
|
|
197
|
+
return "matching";
|
|
198
|
+
}
|
|
199
|
+
if (inspection.workspace.kind === "replaced" ||
|
|
200
|
+
inspection.workspace.kind === "not_a_tutti_project") {
|
|
201
|
+
return "replaced";
|
|
202
|
+
}
|
|
203
|
+
return "unreadable";
|
|
204
|
+
}
|
|
205
|
+
connectOnce(joinTokenMode, initial) {
|
|
206
|
+
if (this.attemptPromise !== undefined) {
|
|
207
|
+
return this.attemptPromise;
|
|
208
|
+
}
|
|
209
|
+
const promise = this.performConnectionAttempt(joinTokenMode, initial);
|
|
210
|
+
this.attemptPromise = promise;
|
|
211
|
+
void promise.then(() => {
|
|
212
|
+
if (this.attemptPromise === promise) {
|
|
213
|
+
this.attemptPromise = undefined;
|
|
214
|
+
}
|
|
215
|
+
}, () => {
|
|
216
|
+
if (this.attemptPromise === promise) {
|
|
217
|
+
this.attemptPromise = undefined;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
return promise;
|
|
221
|
+
}
|
|
222
|
+
async performConnectionAttempt(joinTokenMode, initial) {
|
|
223
|
+
const workspaceAvailability = this.readWorkspaceAvailability();
|
|
224
|
+
if (workspaceAvailability !== "matching") {
|
|
225
|
+
throw new WorkspaceUnavailableError(workspaceAvailability);
|
|
226
|
+
}
|
|
227
|
+
this.clearScheduledTimer();
|
|
228
|
+
if (initial) {
|
|
229
|
+
this.setState({ status: "connecting", attempt: 0 });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
this.setState(this.reconnectingState());
|
|
233
|
+
}
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
this.attemptController = controller;
|
|
236
|
+
let tunnel;
|
|
237
|
+
try {
|
|
238
|
+
const registered = await this.options.registerConnection({
|
|
239
|
+
joinTokenMode,
|
|
240
|
+
signal: controller.signal,
|
|
241
|
+
});
|
|
242
|
+
if (this.stopping || controller.signal.aborted) {
|
|
243
|
+
throw new Error("Relay connection attempt was cancelled");
|
|
244
|
+
}
|
|
245
|
+
tunnel = this.options.connectTunnel(registered);
|
|
246
|
+
this.pendingTunnel = tunnel;
|
|
247
|
+
await tunnel.connected;
|
|
248
|
+
if (this.stopping || controller.signal.aborted) {
|
|
249
|
+
tunnel.close(1000, "host stopping");
|
|
250
|
+
throw new Error("Relay connection attempt was cancelled");
|
|
251
|
+
}
|
|
252
|
+
const connection = mergeKnownJoinUrl(this.lastConnection, registered.summary);
|
|
253
|
+
const previousTunnel = this.activeTunnel;
|
|
254
|
+
this.pendingTunnel = undefined;
|
|
255
|
+
this.activeTunnel = tunnel;
|
|
256
|
+
this.lastConnection = connection;
|
|
257
|
+
this.reconnectFailureCount = 0;
|
|
258
|
+
this.setState({ status: "connected", connection });
|
|
259
|
+
this.watchTunnel(tunnel);
|
|
260
|
+
this.scheduleWorkspaceGuard();
|
|
261
|
+
if (previousTunnel !== undefined && previousTunnel !== tunnel) {
|
|
262
|
+
previousTunnel.close(1000, "connection replaced");
|
|
263
|
+
}
|
|
264
|
+
return connection;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (tunnel !== undefined && this.activeTunnel !== tunnel) {
|
|
268
|
+
tunnel.close(1000, "connection attempt failed");
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (this.pendingTunnel === tunnel) {
|
|
274
|
+
this.pendingTunnel = undefined;
|
|
275
|
+
}
|
|
276
|
+
if (this.attemptController === controller) {
|
|
277
|
+
this.attemptController = undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
watchTunnel(tunnel) {
|
|
282
|
+
void tunnel.closed.then(() => {
|
|
283
|
+
if (this.stopping || this.activeTunnel !== tunnel) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.activeTunnel = undefined;
|
|
287
|
+
this.clearScheduledTimer();
|
|
288
|
+
this.scheduleReconnect();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
beginReconnect() {
|
|
292
|
+
if (this.reconnectPromise !== undefined) {
|
|
293
|
+
return this.reconnectPromise;
|
|
294
|
+
}
|
|
295
|
+
if (this.stopping || this.state.status === "stopped" || this.state.status === "connected") {
|
|
296
|
+
return Promise.resolve();
|
|
297
|
+
}
|
|
298
|
+
const promise = (async () => {
|
|
299
|
+
try {
|
|
300
|
+
await this.connectOnce("reuse_active", false);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.handleReconnectFailure(error);
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
this.reconnectPromise = promise;
|
|
307
|
+
void promise.finally(() => {
|
|
308
|
+
if (this.reconnectPromise === promise) {
|
|
309
|
+
this.reconnectPromise = undefined;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
return promise;
|
|
313
|
+
}
|
|
314
|
+
handleInitialFailure(error) {
|
|
315
|
+
if (this.stopping) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (error instanceof WorkspaceUnavailableError) {
|
|
319
|
+
this.setWorkspaceUnavailable(error.workspaceState, false);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.setState({
|
|
323
|
+
status: "unavailable",
|
|
324
|
+
reason_code: retryableConnectionError(error) ? "relay_unavailable" : "fatal_error",
|
|
325
|
+
error_code: connectionErrorCode(error),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
handleReconnectFailure(error) {
|
|
329
|
+
if (this.stopping) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (error instanceof WorkspaceUnavailableError) {
|
|
333
|
+
this.setWorkspaceUnavailable(error.workspaceState, true);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!retryableConnectionError(error)) {
|
|
337
|
+
this.setState({
|
|
338
|
+
status: "unavailable",
|
|
339
|
+
reason_code: "fatal_error",
|
|
340
|
+
error_code: connectionErrorCode(error),
|
|
341
|
+
...(this.lastConnection === undefined ? {} : { last_connection: this.lastConnection }),
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.reconnectFailureCount += 1;
|
|
346
|
+
this.scheduleReconnect();
|
|
347
|
+
}
|
|
348
|
+
setWorkspaceUnavailable(workspaceState, allowMissingRecheck) {
|
|
349
|
+
const reasonCode = workspaceState === "missing"
|
|
350
|
+
? "workspace_missing"
|
|
351
|
+
: workspaceState === "replaced"
|
|
352
|
+
? "workspace_replaced"
|
|
353
|
+
: "workspace_unreadable";
|
|
354
|
+
if (workspaceState === "missing" && allowMissingRecheck) {
|
|
355
|
+
const nextRetryAt = new Date(this.now().getTime() + this.workspaceCheckIntervalMs).toISOString();
|
|
356
|
+
this.setState({
|
|
357
|
+
status: "unavailable",
|
|
358
|
+
reason_code: reasonCode,
|
|
359
|
+
next_retry_at: nextRetryAt,
|
|
360
|
+
...(this.lastConnection === undefined ? {} : { last_connection: this.lastConnection }),
|
|
361
|
+
});
|
|
362
|
+
this.scheduleTimer(this.workspaceCheckIntervalMs, () => {
|
|
363
|
+
void this.beginReconnect();
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.setState({
|
|
368
|
+
status: "unavailable",
|
|
369
|
+
reason_code: reasonCode,
|
|
370
|
+
...(this.lastConnection === undefined ? {} : { last_connection: this.lastConnection }),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
scheduleReconnect() {
|
|
374
|
+
if (this.stopping) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const index = Math.min(this.reconnectFailureCount, this.retryDelaysMs.length - 1);
|
|
378
|
+
const baseDelay = this.retryDelaysMs[index] ?? 0;
|
|
379
|
+
const jitter = baseDelay === 0 ? 0 : Math.round(baseDelay * this.jitterRatio * (this.random() * 2 - 1));
|
|
380
|
+
const delay = Math.max(0, baseDelay + jitter);
|
|
381
|
+
const nextRetryAt = new Date(this.now().getTime() + delay).toISOString();
|
|
382
|
+
this.setState({
|
|
383
|
+
...this.reconnectingState(),
|
|
384
|
+
next_retry_at: nextRetryAt,
|
|
385
|
+
});
|
|
386
|
+
this.scheduleTimer(delay, () => {
|
|
387
|
+
void this.beginReconnect();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
reconnectingState() {
|
|
391
|
+
return {
|
|
392
|
+
status: "reconnecting",
|
|
393
|
+
attempt: this.reconnectFailureCount + 1,
|
|
394
|
+
...(this.lastConnection === undefined
|
|
395
|
+
? {}
|
|
396
|
+
: {
|
|
397
|
+
last_connected_at: this.lastConnection.connected_at,
|
|
398
|
+
last_connection: this.lastConnection,
|
|
399
|
+
}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
scheduleWorkspaceGuard() {
|
|
403
|
+
this.scheduleTimer(this.workspaceCheckIntervalMs, () => {
|
|
404
|
+
this.runWorkspaceGuard();
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
runWorkspaceGuard() {
|
|
408
|
+
if (this.stopping || this.state.status !== "connected") {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const workspaceAvailability = this.readWorkspaceAvailability();
|
|
412
|
+
if (workspaceAvailability === "matching") {
|
|
413
|
+
this.scheduleWorkspaceGuard();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const tunnel = this.activeTunnel;
|
|
417
|
+
this.activeTunnel = undefined;
|
|
418
|
+
tunnel?.close(1000, "workspace unavailable");
|
|
419
|
+
this.setWorkspaceUnavailable(workspaceAvailability, true);
|
|
420
|
+
}
|
|
421
|
+
scheduleTimer(delayMs, callback) {
|
|
422
|
+
this.clearScheduledTimer();
|
|
423
|
+
this.scheduledTimer = setTimeout(() => {
|
|
424
|
+
this.scheduledTimer = undefined;
|
|
425
|
+
callback();
|
|
426
|
+
}, delayMs);
|
|
427
|
+
}
|
|
428
|
+
clearScheduledTimer() {
|
|
429
|
+
if (this.scheduledTimer !== undefined) {
|
|
430
|
+
clearTimeout(this.scheduledTimer);
|
|
431
|
+
this.scheduledTimer = undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
setState(state) {
|
|
435
|
+
this.state = state;
|
|
436
|
+
try {
|
|
437
|
+
this.options.onStateChange?.(state);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// State observation must not interrupt the connection lifecycle.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
export function createHostRelayConnectionManager(options) {
|
|
445
|
+
return new DefaultHostRelayConnectionManager(options);
|
|
446
|
+
}
|
|
447
|
+
//# sourceMappingURL=host-relay-connection-manager.js.map
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { type ProjectId } from "@tutti/shared/ids";
|
|
1
2
|
import type { HostLocalLaunchStatus } from "../http/routes/local-control.js";
|
|
2
3
|
import type { HostWorkspaceRelayState } from "../http/routes/project-api.js";
|
|
4
|
+
import type { HostRelayConnectionManagerState } from "./host-relay-connection-manager.js";
|
|
3
5
|
import type { HostLaunchRelaySummary } from "./relay-registration.js";
|
|
4
6
|
export declare function relayStateFromLaunchStatus(relayUrl: string, status: HostLocalLaunchStatus | undefined): HostWorkspaceRelayState;
|
|
7
|
+
export declare function relayStateFromConnectionManagerState(relayUrl: string, state: HostRelayConnectionManagerState): HostWorkspaceRelayState;
|
|
8
|
+
export declare function hostLocalLaunchStatusFromConnectionManagerState(projectId: ProjectId, state: HostRelayConnectionManagerState): HostLocalLaunchStatus;
|
|
5
9
|
export declare function relayStatusFromLocalStatus(status: HostLocalLaunchStatus): HostLaunchRelaySummary | undefined;
|
|
6
10
|
//# sourceMappingURL=host-relay-status.d.ts.map
|
|
@@ -1,22 +1,81 @@
|
|
|
1
|
-
import { ID_PREFIXES, isPrefixedId
|
|
1
|
+
import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
|
|
2
|
+
function relayProjection(relayUrl, status, relay) {
|
|
3
|
+
return {
|
|
4
|
+
status,
|
|
5
|
+
relay_url: relayUrl,
|
|
6
|
+
...(relay === undefined
|
|
7
|
+
? {}
|
|
8
|
+
: {
|
|
9
|
+
relay_project_ref: relay.relay_project_ref,
|
|
10
|
+
...(relay.join_url === undefined ? {} : { join_url: relay.join_url }),
|
|
11
|
+
join_url_visibility: relay.join_url_visibility,
|
|
12
|
+
...(relay.join_token_expires_at === undefined
|
|
13
|
+
? {}
|
|
14
|
+
: { join_token_expires_at: relay.join_token_expires_at }),
|
|
15
|
+
}),
|
|
16
|
+
join_token_reusable: relay?.join_token_reusable ?? true,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
2
19
|
export function relayStateFromLaunchStatus(relayUrl, status) {
|
|
20
|
+
const connectionStatus = status?.relay_connection?.status;
|
|
21
|
+
if (connectionStatus === "connecting" || connectionStatus === "reconnecting") {
|
|
22
|
+
return relayProjection(relayUrl, "reconnecting", status?.relay);
|
|
23
|
+
}
|
|
24
|
+
if (connectionStatus === "unavailable" || connectionStatus === "stopped") {
|
|
25
|
+
return relayProjection(relayUrl, "unavailable", status?.relay);
|
|
26
|
+
}
|
|
3
27
|
if (status?.relay === undefined) {
|
|
4
|
-
return
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
28
|
+
return relayProjection(relayUrl, "unavailable", undefined);
|
|
29
|
+
}
|
|
30
|
+
return relayProjection(relayUrl, "connected", status.relay);
|
|
31
|
+
}
|
|
32
|
+
export function relayStateFromConnectionManagerState(relayUrl, state) {
|
|
33
|
+
if (state.status === "connected") {
|
|
34
|
+
return relayProjection(relayUrl, "connected", state.connection);
|
|
35
|
+
}
|
|
36
|
+
if (state.status === "connecting" || state.status === "reconnecting") {
|
|
37
|
+
return relayProjection(relayUrl, "reconnecting", state.status === "reconnecting" ? state.last_connection : undefined);
|
|
9
38
|
}
|
|
39
|
+
return relayProjection(relayUrl, "unavailable", state.status === "unavailable" ? state.last_connection : undefined);
|
|
40
|
+
}
|
|
41
|
+
export function hostLocalLaunchStatusFromConnectionManagerState(projectId, state) {
|
|
42
|
+
const relay = state.status === "connected"
|
|
43
|
+
? state.connection
|
|
44
|
+
: state.status === "reconnecting" || state.status === "unavailable"
|
|
45
|
+
? state.last_connection
|
|
46
|
+
: undefined;
|
|
47
|
+
const connection = state.status === "connected"
|
|
48
|
+
? {
|
|
49
|
+
status: "connected",
|
|
50
|
+
last_connected_at: state.connection.connected_at,
|
|
51
|
+
}
|
|
52
|
+
: state.status === "connecting"
|
|
53
|
+
? { status: "connecting", attempt: state.attempt }
|
|
54
|
+
: state.status === "reconnecting"
|
|
55
|
+
? {
|
|
56
|
+
status: "reconnecting",
|
|
57
|
+
attempt: state.attempt,
|
|
58
|
+
...(state.next_retry_at === undefined ? {} : { next_retry_at: state.next_retry_at }),
|
|
59
|
+
...(state.last_connected_at === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: { last_connected_at: state.last_connected_at }),
|
|
62
|
+
}
|
|
63
|
+
: state.status === "unavailable"
|
|
64
|
+
? {
|
|
65
|
+
status: "unavailable",
|
|
66
|
+
reason_code: state.reason_code,
|
|
67
|
+
...(state.next_retry_at === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { next_retry_at: state.next_retry_at }),
|
|
70
|
+
...(state.last_connection === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { last_connected_at: state.last_connection.connected_at }),
|
|
73
|
+
}
|
|
74
|
+
: { status: "stopped" };
|
|
10
75
|
return {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
...(status.relay.join_url === undefined ? {} : { join_url: status.relay.join_url }),
|
|
15
|
-
join_url_visibility: status.relay.join_url_visibility,
|
|
16
|
-
...(status.relay.join_token_expires_at === undefined
|
|
17
|
-
? {}
|
|
18
|
-
: { join_token_expires_at: status.relay.join_token_expires_at }),
|
|
19
|
-
join_token_reusable: status.relay.join_token_reusable ?? true,
|
|
76
|
+
project_id: projectId,
|
|
77
|
+
relay_connection: connection,
|
|
78
|
+
...(relay === undefined ? {} : { relay }),
|
|
20
79
|
};
|
|
21
80
|
}
|
|
22
81
|
export function relayStatusFromLocalStatus(status) {
|
|
@@ -95,6 +95,32 @@ function isLaunchStatusPayload(value) {
|
|
|
95
95
|
return false;
|
|
96
96
|
}
|
|
97
97
|
const record = value;
|
|
98
|
+
if (record.project_id !== undefined && typeof record.project_id !== "string") {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (record.relay_connection !== undefined) {
|
|
102
|
+
if (typeof record.relay_connection !== "object" ||
|
|
103
|
+
record.relay_connection === null ||
|
|
104
|
+
Array.isArray(record.relay_connection)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const connection = record.relay_connection;
|
|
108
|
+
if (connection.status !== "connecting" &&
|
|
109
|
+
connection.status !== "connected" &&
|
|
110
|
+
connection.status !== "reconnecting" &&
|
|
111
|
+
connection.status !== "unavailable" &&
|
|
112
|
+
connection.status !== "stopped") {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if ((connection.attempt !== undefined &&
|
|
116
|
+
(!Number.isInteger(connection.attempt) || connection.attempt < 0)) ||
|
|
117
|
+
(connection.next_retry_at !== undefined && typeof connection.next_retry_at !== "string") ||
|
|
118
|
+
(connection.last_connected_at !== undefined &&
|
|
119
|
+
typeof connection.last_connected_at !== "string") ||
|
|
120
|
+
(connection.reason_code !== undefined && typeof connection.reason_code !== "string")) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
98
124
|
if (record.relay === undefined) {
|
|
99
125
|
return true;
|
|
100
126
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { FastifyInstance } from "fastify";
|
|
2
|
-
import { type RelayHostTunnelHandle } from "@tutti/relay-client";
|
|
3
2
|
import { type HostProjectStore } from "../../store/index.js";
|
|
4
3
|
import type { createHostServer } from "../http/create-server.js";
|
|
5
4
|
import type { HostLocalLaunchStatus } from "../http/routes/local-control.js";
|
|
6
5
|
import type { HostWorkspaceRelayState } from "../http/routes/project-api/types.js";
|
|
7
6
|
import { type TrustedRelaySessionMetadataStore } from "../session/relay-session-context.js";
|
|
7
|
+
import type { HostRelayConnectionManager } from "./host-relay-connection-manager.js";
|
|
8
8
|
import type { LaunchPreparationResult } from "./launch.js";
|
|
9
9
|
import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
|
|
10
10
|
export declare const HOST_SERVER_VERSION = "0.0.0";
|
|
@@ -15,7 +15,7 @@ export type HostServerHandle = {
|
|
|
15
15
|
log_file_path: string;
|
|
16
16
|
runtime_endpoint: MachineRuntimeEndpointRecord;
|
|
17
17
|
trusted_relay_metadata_store: TrustedRelaySessionMetadataStore;
|
|
18
|
-
|
|
18
|
+
attachRelayConnectionManager: (manager: HostRelayConnectionManager) => void;
|
|
19
19
|
closed: Promise<void>;
|
|
20
20
|
close: () => Promise<void>;
|
|
21
21
|
};
|