@xfey/tutti 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/server-shell/cli/args.d.ts +9 -0
  3. package/dist/server-shell/cli/args.js +55 -5
  4. package/dist/server-shell/cli/cli.js +46 -7
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/errors.js +6 -1
  7. package/dist/server-shell/cli/host-lifecycle.d.ts +1 -0
  8. package/dist/server-shell/cli/host-lifecycle.js +155 -41
  9. package/dist/server-shell/cli/host-relay-connection-manager.d.ts +50 -0
  10. package/dist/server-shell/cli/host-relay-connection-manager.js +447 -0
  11. package/dist/server-shell/cli/host-relay-status.d.ts +4 -0
  12. package/dist/server-shell/cli/host-relay-status.js +74 -15
  13. package/dist/server-shell/cli/host-runtime-endpoint.js +26 -0
  14. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -2
  15. package/dist/server-shell/cli/host-server-runtime.js +19 -15
  16. package/dist/server-shell/cli/launch-command.d.ts +1 -0
  17. package/dist/server-shell/cli/launch-command.js +8 -10
  18. package/dist/server-shell/cli/launch.d.ts +1 -0
  19. package/dist/server-shell/cli/launch.js +7 -3
  20. package/dist/server-shell/cli/local-control-client.d.ts +5 -0
  21. package/dist/server-shell/cli/local-control-client.js +9 -0
  22. package/dist/server-shell/cli/machine-local.d.ts +8 -0
  23. package/dist/server-shell/cli/machine-local.js +29 -0
  24. package/dist/server-shell/cli/machine-project-inspector.d.ts +81 -0
  25. package/dist/server-shell/cli/machine-project-inspector.js +205 -0
  26. package/dist/server-shell/cli/project-resolver.js +22 -32
  27. package/dist/server-shell/cli/relay-registration.d.ts +5 -2
  28. package/dist/server-shell/cli/relay-registration.js +7 -3
  29. package/dist/server-shell/cli/runtime-commands.d.ts +30 -4
  30. package/dist/server-shell/cli/runtime-commands.js +230 -113
  31. package/dist/server-shell/http/routes/local-control.d.ts +10 -0
  32. package/dist/server-shell/http/routes/local-control.js +29 -0
  33. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +23 -0
  34. package/node_modules/@tutti/relay-client/dist/host-control.js +70 -3
  35. package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +6 -2
  36. package/node_modules/@tutti/relay-client/dist/tunnel.js +13 -2
  37. package/package.json +1 -1
  38. package/web/assets/index-Du5Txb6C.js +29 -0
  39. package/web/index.html +1 -1
  40. package/web/assets/index-DPU2uqZl.js +0 -29
@@ -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, } from "@tutti/shared/ids";
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
- status: "unavailable",
6
- relay_url: relayUrl,
7
- join_token_reusable: true,
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
- status: "connected",
12
- relay_url: relayUrl,
13
- relay_project_ref: status.relay.relay_project_ref,
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
- attachRelayTunnel: (tunnel: RelayHostTunnelHandle) => void;
18
+ attachRelayConnectionManager: (manager: HostRelayConnectionManager) => void;
19
19
  closed: Promise<void>;
20
20
  close: () => Promise<void>;
21
21
  };