authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 19:53:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 19:53:51-07:00
log6b456a9618c35f2fa525f6dfc9ef9b0ff01471b8
tree771c38bf62896c2460e0f2564c299b112672d00d
parent6bfc8baeb8f280e57ef6d0d514680b0bce8538a2
signature Signed by PGP key B5690EEEBB952194

feat(connect): tell users when an idle tunnel was removed (#13567)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

21 files changed, 1932 insertions(+), 24 deletions(-)

apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts+21
...@@ -2,6 +2,8 @@ import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contract...@@ -2,6 +2,8 @@ import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contract
2import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";2import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { RELAY_TUNNEL_RELEASED_MESSAGE } from "@t3tools/client-runtime/relay";
6
5import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";7import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";
68
7function relayStatus(9function relayStatus(
...@@ -111,4 +113,23 @@ describe("available cloud environment presentation", () => {...@@ -111,4 +113,23 @@ describe("available cloud environment presentation", () => {
111 connectionErrorTraceId: "trace-status",113 connectionErrorTraceId: "trace-status",
112 });114 });
113 });115 });
116
117 it("tells the user to update a host whose idle tunnel was removed", () => {
118 const status = {
119 ...relayStatus("offline", "Managed endpoint health request failed.", "trace-released"),
120 offlineReason: "tunnel_released",
121 } satisfies RelayEnvironmentStatusResponse;
122 expect(
123 availableCloudEnvironmentPresentation({
124 isStatusPending: false,
125 status,
126 statusError: null,
127 statusErrorTraceId: null,
128 }),
129 ).toMatchObject({
130 connectionState: "error",
131 statusText: RELAY_TUNNEL_RELEASED_MESSAGE,
132 connectionErrorTraceId: "trace-released",
133 });
134 });
114});135});
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts+3-1
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 orchestrationProtocolCompatibilityError,3 orchestrationProtocolCompatibilityError,
4 type EnvironmentConnectionPhase,4 type EnvironmentConnectionPhase,
5} from "@t3tools/client-runtime/connection";5} from "@t3tools/client-runtime/connection";
6import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
67
7export interface AvailableCloudEnvironmentPresentation {8export interface AvailableCloudEnvironmentPresentation {
8 readonly connectionError: string | null;9 readonly connectionError: string | null;
...@@ -39,7 +40,8 @@ export function availableCloudEnvironmentPresentation(input: {...@@ -39,7 +40,8 @@ export function availableCloudEnvironmentPresentation(input: {
39 }40 }
4041
41 if (input.status?.status === "offline") {42 if (input.status?.status === "offline") {
42 const connectionError = input.status.error ?? "Relay is offline.";43 const connectionError =
44 relayOfflineReasonMessage(input.status) ?? input.status.error ?? "Relay is offline.";
43 return {45 return {
44 connectionError,46 connectionError,
45 connectionErrorTraceId: input.status.traceId ?? null,47 connectionErrorTraceId: input.status.traceId ?? null,
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+35-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { Discovery } from "@t3tools/client-runtime/relay";1import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";3import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -152,6 +152,40 @@ afterEach(async () => {...@@ -152,6 +152,40 @@ afterEach(async () => {
152 vi.unstubAllGlobals();152 vi.unstubAllGlobals();
153});153});
154154
155describe("cloud environment offline reasons", () => {
156 it("tells the user to update a computer whose idle tunnel was removed", async () => {
157 const base = linkedMachines.get(newMachineId)!;
158 const offlineEntry = (offlineReason?: "tunnel_released") => ({
159 ...base,
160 availability: "offline" as const,
161 status: Option.some({
162 environmentId: newMachineId,
163 endpoint: base.environment.endpoint,
164 status: "offline" as const,
165 checkedAt: "2026-09-15T00:00:00Z",
166 error: "Managed endpoint health request failed.",
167 ...(offlineReason ? { offlineReason } : {}),
168 }),
169 });
170 const rowText = () =>
171 renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
172
173 discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]]));
174 await mount();
175 expect(rowText()).toContain("T3 Connect · Not added · Relay offline");
176
177 await act(async () => {
178 publish({
179 environments: new Map([[newMachineId, offlineEntry("tunnel_released")]]),
180 refreshing: false,
181 offline: false,
182 error: Option.none(),
183 });
184 });
185 expect(rowText()).toContain(RELAY_TUNNEL_RELEASED_MESSAGE);
186 });
187});
188
155describe("cloud onboarding discovery", () => {189describe("cloud onboarding discovery", () => {
156 it("signals that the section can expand after initial discovery settles", async () => {190 it("signals that the section can expand after initial discovery settles", async () => {
157 let finishDiscovery!: (environments: DiscoveredEnvironments) => void;191 let finishDiscovery!: (environments: DiscoveredEnvironments) => void;
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+32-20
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 RelayConnectionTarget,5 RelayConnectionTarget,
6 orchestrationProtocolCompatibilityError,6 orchestrationProtocolCompatibilityError,
7} from "@t3tools/client-runtime/connection";7} from "@t3tools/client-runtime/connection";
8import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
8import {9import {
9 isAtomCommandInterrupted,10 isAtomCommandInterrupted,
10 squashAtomCommandFailure,11 squashAtomCommandFailure,
...@@ -335,7 +336,14 @@ export function CloudEnvironmentConnectRows({...@@ -335,7 +336,14 @@ export function CloudEnvironmentConnectRows({
335 // A connected machine's own config (with the user's icon pick) wins. Before336 // A connected machine's own config (with the user's icon pick) wins. Before
336 // that, the relay's health probe already carries the server's descriptor, so337 // that, the relay's health probe already carries the server's descriptor, so
337 // a machine can wear its detected glyph before this device ever connects.338 // a machine can wear its detected glyph before this device ever connects.
338 const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor;339 const relayStatus = status === undefined ? null : Option.getOrNull(status);
340 const descriptor = relayStatus?.descriptor;
341 // Why the relay reports this environment offline, when it knows more than
342 // "no answer". Shown for saved and unsaved rows alike.
343 const offlineReason =
344 availability === "offline" && relayStatus !== null
345 ? relayOfflineReasonMessage(relayStatus)
346 : null;
339 const machineKind = resolveEnvironmentMachineKind(347 const machineKind = resolveEnvironmentMachineKind(
340 savedEnvironment?.serverConfig ??348 savedEnvironment?.serverConfig ??
341 (descriptor === undefined ? null : { environment: descriptor }),349 (descriptor === undefined ? null : { environment: descriptor }),
...@@ -361,16 +369,18 @@ export function CloudEnvironmentConnectRows({...@@ -361,16 +369,18 @@ export function CloudEnvironmentConnectRows({
361 const statusText =369 const statusText =
362 unsupported && !savedEnvironment370 unsupported && !savedEnvironment
363 ? `T3 Connect · ${notAdded} · Client not supported`371 ? `T3 Connect · ${notAdded} · Client not supported`
364 : savedConnection372 : offlineReason !== null
365 ? savedConnection.statusText373 ? offlineReason
366 : availability === "online"374 : savedConnection
367 ? `T3 Connect · ${notAdded} · Relay online`375 ? savedConnection.statusText
368 : availability === "offline"376 : availability === "online"
369 ? `T3 Connect · ${notAdded} · Relay offline`377 ? `T3 Connect · ${notAdded} · Relay online`
370 : availability === "checking"378 : availability === "offline"
371 ? `T3 Connect · ${notAdded} · Checking relay status…`379 ? `T3 Connect · ${notAdded} · Relay offline`
372 : (Option.getOrNull(error)?.message ??380 : availability === "checking"
373 `T3 Connect · ${notAdded} · Relay status unavailable`);381 ? `T3 Connect · ${notAdded} · Checking relay status…`
382 : (Option.getOrNull(error)?.message ??
383 `T3 Connect · ${notAdded} · Relay status unavailable`);
374 if (selection) {384 if (selection) {
375 return (385 return (
376 <label386 <label
...@@ -435,15 +445,17 @@ export function CloudEnvironmentConnectRows({...@@ -435,15 +445,17 @@ export function CloudEnvironmentConnectRows({
435 tooltipText={445 tooltipText={
436 unsupportedDetail !== null446 unsupportedDetail !== null
437 ? unsupportedDetail447 ? unsupportedDetail
438 : savedConnection448 : offlineReason !== null
439 ? savedConnection.statusText449 ? offlineReason
440 : availability === "online"450 : savedConnection
441 ? "Relay online"451 ? savedConnection.statusText
442 : availability === "offline"452 : availability === "online"
443 ? "Relay offline"453 ? "Relay online"
444 : availability === "checking"454 : availability === "offline"
445 ? "Checking relay status"455 ? "Relay offline"
446 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")456 : availability === "checking"
457 ? "Checking relay status"
458 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
447 }459 }
448 />460 />
449 <EnvironmentMachineIcon461 <EnvironmentMachineIcon
docs/user/remote-access.md+5
...@@ -194,6 +194,11 @@ and keeps the same address. When the host starts again or wakes, T3 Connect...@@ -194,6 +194,11 @@ and keeps the same address. When the host starts again or wakes, T3 Connect
194creates a replacement tunnel on its own. You do not need to pair again. Cleanup194creates a replacement tunnel on its own. You do not need to pair again. Cleanup
195usually runs five to ten minutes after the tunnel goes down.195usually runs five to ten minutes after the tunnel goes down.
196196
197T3 Connect also removes the tunnel of an environment running an older version of
198T3 Code once it has been offline for seven days. That environment shows a message
199asking you to update. Start T3 Code on that computer and update it to the latest
200version; it reconnects at the same address without pairing again.
201
197On a command-line host, `t3 connect unlink` disables exposure while retaining202On a command-line host, `t3 connect unlink` disables exposure while retaining
198your login; `t3 connect logout` also clears that login. Background-service203your login; `t3 connect logout` also clears that login. Background-service
199[removal](./background-service.md#manage-the-service) is separate.204[removal](./background-service.md#manage-the-service) is separate.
infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql created+1
...@@ -0,0 +1 @@
1ALTER TABLE "relay_managed_endpoint_allocations" ADD COLUMN "tunnel_released_at" varchar(64);
\ No newline at end of file
infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/snapshot.json created+1594
...@@ -0,0 +1,1594 @@
1{
2 "version": "8",
3 "dialect": "postgres",
4 "id": "7b10c420-1566-4dfc-8e9c-c73efa561d3b",
5 "prevIds": ["13972659-45db-4ce9-9818-523b450bceca"],
6 "ddl": [
7 {
8 "isRlsEnabled": false,
9 "name": "relay_agent_activity_rows",
10 "entityType": "tables",
11 "schema": "public"
12 },
13 {
14 "isRlsEnabled": false,
15 "name": "relay_delivery_attempts",
16 "entityType": "tables",
17 "schema": "public"
18 },
19 {
20 "isRlsEnabled": false,
21 "name": "relay_dpop_proofs",
22 "entityType": "tables",
23 "schema": "public"
24 },
25 {
26 "isRlsEnabled": false,
27 "name": "relay_environment_credentials",
28 "entityType": "tables",
29 "schema": "public"
30 },
31 {
32 "isRlsEnabled": false,
33 "name": "relay_environment_links",
34 "entityType": "tables",
35 "schema": "public"
36 },
37 {
38 "isRlsEnabled": false,
39 "name": "relay_live_activities",
40 "entityType": "tables",
41 "schema": "public"
42 },
43 {
44 "isRlsEnabled": false,
45 "name": "relay_managed_endpoint_allocations",
46 "entityType": "tables",
47 "schema": "public"
48 },
49 {
50 "isRlsEnabled": false,
51 "name": "relay_managed_tunnel_limits",
52 "entityType": "tables",
53 "schema": "public"
54 },
55 {
56 "isRlsEnabled": false,
57 "name": "relay_mobile_devices",
58 "entityType": "tables",
59 "schema": "public"
60 },
61 {
62 "type": "varchar(191)",
63 "typeSchema": null,
64 "notNull": true,
65 "dimensions": 0,
66 "default": null,
67 "generated": null,
68 "identity": null,
69 "name": "environment_id",
70 "entityType": "columns",
71 "schema": "public",
72 "table": "relay_agent_activity_rows"
73 },
74 {
75 "type": "text",
76 "typeSchema": null,
77 "notNull": true,
78 "dimensions": 0,
79 "default": null,
80 "generated": null,
81 "identity": null,
82 "name": "environment_public_key",
83 "entityType": "columns",
84 "schema": "public",
85 "table": "relay_agent_activity_rows"
86 },
87 {
88 "type": "varchar(512)",
89 "typeSchema": null,
90 "notNull": true,
91 "dimensions": 0,
92 "default": null,
93 "generated": null,
94 "identity": null,
95 "name": "thread_id",
96 "entityType": "columns",
97 "schema": "public",
98 "table": "relay_agent_activity_rows"
99 },
100 {
101 "type": "jsonb",
102 "typeSchema": null,
103 "notNull": true,
104 "dimensions": 0,
105 "default": null,
106 "generated": null,
107 "identity": null,
108 "name": "state_json",
109 "entityType": "columns",
110 "schema": "public",
111 "table": "relay_agent_activity_rows"
112 },
113 {
114 "type": "varchar(64)",
115 "typeSchema": null,
116 "notNull": true,
117 "dimensions": 0,
118 "default": null,
119 "generated": null,
120 "identity": null,
121 "name": "updated_at",
122 "entityType": "columns",
123 "schema": "public",
124 "table": "relay_agent_activity_rows"
125 },
126 {
127 "type": "varchar(64)",
128 "typeSchema": null,
129 "notNull": true,
130 "dimensions": 0,
131 "default": null,
132 "generated": null,
133 "identity": null,
134 "name": "created_at",
135 "entityType": "columns",
136 "schema": "public",
137 "table": "relay_agent_activity_rows"
138 },
139 {
140 "type": "varchar(36)",
141 "typeSchema": null,
142 "notNull": true,
143 "dimensions": 0,
144 "default": null,
145 "generated": null,
146 "identity": null,
147 "name": "id",
148 "entityType": "columns",
149 "schema": "public",
150 "table": "relay_delivery_attempts"
151 },
152 {
153 "type": "varchar(64)",
154 "typeSchema": null,
155 "notNull": true,
156 "dimensions": 0,
157 "default": null,
158 "generated": null,
159 "identity": null,
160 "name": "created_at",
161 "entityType": "columns",
162 "schema": "public",
163 "table": "relay_delivery_attempts"
164 },
165 {
166 "type": "varchar(255)",
167 "typeSchema": null,
168 "notNull": false,
169 "dimensions": 0,
170 "default": null,
171 "generated": null,
172 "identity": null,
173 "name": "user_id",
174 "entityType": "columns",
175 "schema": "public",
176 "table": "relay_delivery_attempts"
177 },
178 {
179 "type": "varchar(191)",
180 "typeSchema": null,
181 "notNull": false,
182 "dimensions": 0,
183 "default": null,
184 "generated": null,
185 "identity": null,
186 "name": "environment_id",
187 "entityType": "columns",
188 "schema": "public",
189 "table": "relay_delivery_attempts"
190 },
191 {
192 "type": "varchar(512)",
193 "typeSchema": null,
194 "notNull": false,
195 "dimensions": 0,
196 "default": null,
197 "generated": null,
198 "identity": null,
199 "name": "thread_id",
200 "entityType": "columns",
201 "schema": "public",
202 "table": "relay_delivery_attempts"
203 },
204 {
205 "type": "varchar(255)",
206 "typeSchema": null,
207 "notNull": false,
208 "dimensions": 0,
209 "default": null,
210 "generated": null,
211 "identity": null,
212 "name": "device_id",
213 "entityType": "columns",
214 "schema": "public",
215 "table": "relay_delivery_attempts"
216 },
217 {
218 "type": "varchar(64)",
219 "typeSchema": null,
220 "notNull": true,
221 "dimensions": 0,
222 "default": null,
223 "generated": null,
224 "identity": null,
225 "name": "kind",
226 "entityType": "columns",
227 "schema": "public",
228 "table": "relay_delivery_attempts"
229 },
230 {
231 "type": "varchar(64)",
232 "typeSchema": null,
233 "notNull": false,
234 "dimensions": 0,
235 "default": null,
236 "generated": null,
237 "identity": null,
238 "name": "source_job_id",
239 "entityType": "columns",
240 "schema": "public",
241 "table": "relay_delivery_attempts"
242 },
243 {
244 "type": "varchar(16)",
245 "typeSchema": null,
246 "notNull": false,
247 "dimensions": 0,
248 "default": null,
249 "generated": null,
250 "identity": null,
251 "name": "token_suffix",
252 "entityType": "columns",
253 "schema": "public",
254 "table": "relay_delivery_attempts"
255 },
256 {
257 "type": "integer",
258 "typeSchema": null,
259 "notNull": false,
260 "dimensions": 0,
261 "default": null,
262 "generated": null,
263 "identity": null,
264 "name": "apns_status",
265 "entityType": "columns",
266 "schema": "public",
267 "table": "relay_delivery_attempts"
268 },
269 {
270 "type": "text",
271 "typeSchema": null,
272 "notNull": false,
273 "dimensions": 0,
274 "default": null,
275 "generated": null,
276 "identity": null,
277 "name": "apns_reason",
278 "entityType": "columns",
279 "schema": "public",
280 "table": "relay_delivery_attempts"
281 },
282 {
283 "type": "varchar(128)",
284 "typeSchema": null,
285 "notNull": false,
286 "dimensions": 0,
287 "default": null,
288 "generated": null,
289 "identity": null,
290 "name": "apns_id",
291 "entityType": "columns",
292 "schema": "public",
293 "table": "relay_delivery_attempts"
294 },
295 {
296 "type": "text",
297 "typeSchema": null,
298 "notNull": false,
299 "dimensions": 0,
300 "default": null,
301 "generated": null,
302 "identity": null,
303 "name": "transport_error",
304 "entityType": "columns",
305 "schema": "public",
306 "table": "relay_delivery_attempts"
307 },
308 {
309 "type": "varchar(128)",
310 "typeSchema": null,
311 "notNull": true,
312 "dimensions": 0,
313 "default": null,
314 "generated": null,
315 "identity": null,
316 "name": "thumbprint",
317 "entityType": "columns",
318 "schema": "public",
319 "table": "relay_dpop_proofs"
320 },
321 {
322 "type": "varchar(255)",
323 "typeSchema": null,
324 "notNull": true,
325 "dimensions": 0,
326 "default": null,
327 "generated": null,
328 "identity": null,
329 "name": "jti",
330 "entityType": "columns",
331 "schema": "public",
332 "table": "relay_dpop_proofs"
333 },
334 {
335 "type": "integer",
336 "typeSchema": null,
337 "notNull": true,
338 "dimensions": 0,
339 "default": null,
340 "generated": null,
341 "identity": null,
342 "name": "iat",
343 "entityType": "columns",
344 "schema": "public",
345 "table": "relay_dpop_proofs"
346 },
347 {
348 "type": "varchar(64)",
349 "typeSchema": null,
350 "notNull": true,
351 "dimensions": 0,
352 "default": null,
353 "generated": null,
354 "identity": null,
355 "name": "expires_at",
356 "entityType": "columns",
357 "schema": "public",
358 "table": "relay_dpop_proofs"
359 },
360 {
361 "type": "varchar(64)",
362 "typeSchema": null,
363 "notNull": true,
364 "dimensions": 0,
365 "default": null,
366 "generated": null,
367 "identity": null,
368 "name": "created_at",
369 "entityType": "columns",
370 "schema": "public",
371 "table": "relay_dpop_proofs"
372 },
373 {
374 "type": "varchar(64)",
375 "typeSchema": null,
376 "notNull": true,
377 "dimensions": 0,
378 "default": null,
379 "generated": null,
380 "identity": null,
381 "name": "credential_id",
382 "entityType": "columns",
383 "schema": "public",
384 "table": "relay_environment_credentials"
385 },
386 {
387 "type": "varchar(191)",
388 "typeSchema": null,
389 "notNull": true,
390 "dimensions": 0,
391 "default": null,
392 "generated": null,
393 "identity": null,
394 "name": "environment_id",
395 "entityType": "columns",
396 "schema": "public",
397 "table": "relay_environment_credentials"
398 },
399 {
400 "type": "text",
401 "typeSchema": null,
402 "notNull": true,
403 "dimensions": 0,
404 "default": null,
405 "generated": null,
406 "identity": null,
407 "name": "environment_public_key",
408 "entityType": "columns",
409 "schema": "public",
410 "table": "relay_environment_credentials"
411 },
412 {
413 "type": "varchar(191)",
414 "typeSchema": null,
415 "notNull": true,
416 "dimensions": 0,
417 "default": null,
418 "generated": null,
419 "identity": null,
420 "name": "credential_hash",
421 "entityType": "columns",
422 "schema": "public",
423 "table": "relay_environment_credentials"
424 },
425 {
426 "type": "varchar(64)",
427 "typeSchema": null,
428 "notNull": false,
429 "dimensions": 0,
430 "default": null,
431 "generated": null,
432 "identity": null,
433 "name": "revoked_at",
434 "entityType": "columns",
435 "schema": "public",
436 "table": "relay_environment_credentials"
437 },
438 {
439 "type": "varchar(64)",
440 "typeSchema": null,
441 "notNull": true,
442 "dimensions": 0,
443 "default": null,
444 "generated": null,
445 "identity": null,
446 "name": "created_at",
447 "entityType": "columns",
448 "schema": "public",
449 "table": "relay_environment_credentials"
450 },
451 {
452 "type": "varchar(64)",
453 "typeSchema": null,
454 "notNull": true,
455 "dimensions": 0,
456 "default": null,
457 "generated": null,
458 "identity": null,
459 "name": "updated_at",
460 "entityType": "columns",
461 "schema": "public",
462 "table": "relay_environment_credentials"
463 },
464 {
465 "type": "varchar(191)",
466 "typeSchema": null,
467 "notNull": true,
468 "dimensions": 0,
469 "default": null,
470 "generated": null,
471 "identity": null,
472 "name": "user_id",
473 "entityType": "columns",
474 "schema": "public",
475 "table": "relay_environment_links"
476 },
477 {
478 "type": "varchar(191)",
479 "typeSchema": null,
480 "notNull": true,
481 "dimensions": 0,
482 "default": null,
483 "generated": null,
484 "identity": null,
485 "name": "environment_id",
486 "entityType": "columns",
487 "schema": "public",
488 "table": "relay_environment_links"
489 },
490 {
491 "type": "text",
492 "typeSchema": null,
493 "notNull": true,
494 "dimensions": 0,
495 "default": "'T3 Environment'",
496 "generated": null,
497 "identity": null,
498 "name": "environment_label",
499 "entityType": "columns",
500 "schema": "public",
501 "table": "relay_environment_links"
502 },
503 {
504 "type": "text",
505 "typeSchema": null,
506 "notNull": true,
507 "dimensions": 0,
508 "default": null,
509 "generated": null,
510 "identity": null,
511 "name": "environment_public_key",
512 "entityType": "columns",
513 "schema": "public",
514 "table": "relay_environment_links"
515 },
516 {
517 "type": "text",
518 "typeSchema": null,
519 "notNull": true,
520 "dimensions": 0,
521 "default": null,
522 "generated": null,
523 "identity": null,
524 "name": "endpoint_http_base_url",
525 "entityType": "columns",
526 "schema": "public",
527 "table": "relay_environment_links"
528 },
529 {
530 "type": "text",
531 "typeSchema": null,
532 "notNull": true,
533 "dimensions": 0,
534 "default": null,
535 "generated": null,
536 "identity": null,
537 "name": "endpoint_ws_base_url",
538 "entityType": "columns",
539 "schema": "public",
540 "table": "relay_environment_links"
541 },
542 {
543 "type": "varchar(32)",
544 "typeSchema": null,
545 "notNull": true,
546 "dimensions": 0,
547 "default": null,
548 "generated": null,
549 "identity": null,
550 "name": "endpoint_provider_kind",
551 "entityType": "columns",
552 "schema": "public",
553 "table": "relay_environment_links"
554 },
555 {
556 "type": "boolean",
557 "typeSchema": null,
558 "notNull": true,
559 "dimensions": 0,
560 "default": "true",
561 "generated": null,
562 "identity": null,
563 "name": "notifications_enabled",
564 "entityType": "columns",
565 "schema": "public",
566 "table": "relay_environment_links"
567 },
568 {
569 "type": "boolean",
570 "typeSchema": null,
571 "notNull": true,
572 "dimensions": 0,
573 "default": "true",
574 "generated": null,
575 "identity": null,
576 "name": "live_activities_enabled",
577 "entityType": "columns",
578 "schema": "public",
579 "table": "relay_environment_links"
580 },
581 {
582 "type": "boolean",
583 "typeSchema": null,
584 "notNull": true,
585 "dimensions": 0,
586 "default": "false",
587 "generated": null,
588 "identity": null,
589 "name": "managed_tunnels_enabled",
590 "entityType": "columns",
591 "schema": "public",
592 "table": "relay_environment_links"
593 },
594 {
595 "type": "boolean",
596 "typeSchema": null,
597 "notNull": true,
598 "dimensions": 0,
599 "default": "false",
600 "generated": null,
601 "identity": null,
602 "name": "hold_webhooks_while_offline",
603 "entityType": "columns",
604 "schema": "public",
605 "table": "relay_environment_links"
606 },
607 {
608 "type": "varchar(191)",
609 "typeSchema": null,
610 "notNull": false,
611 "dimensions": 0,
612 "default": null,
613 "generated": null,
614 "identity": null,
615 "name": "created_by_device_id",
616 "entityType": "columns",
617 "schema": "public",
618 "table": "relay_environment_links"
619 },
620 {
621 "type": "varchar(64)",
622 "typeSchema": null,
623 "notNull": false,
624 "dimensions": 0,
625 "default": null,
626 "generated": null,
627 "identity": null,
628 "name": "revoked_at",
629 "entityType": "columns",
630 "schema": "public",
631 "table": "relay_environment_links"
632 },
633 {
634 "type": "varchar(64)",
635 "typeSchema": null,
636 "notNull": true,
637 "dimensions": 0,
638 "default": null,
639 "generated": null,
640 "identity": null,
641 "name": "created_at",
642 "entityType": "columns",
643 "schema": "public",
644 "table": "relay_environment_links"
645 },
646 {
647 "type": "varchar(64)",
648 "typeSchema": null,
649 "notNull": true,
650 "dimensions": 0,
651 "default": null,
652 "generated": null,
653 "identity": null,
654 "name": "updated_at",
655 "entityType": "columns",
656 "schema": "public",
657 "table": "relay_environment_links"
658 },
659 {
660 "type": "varchar(255)",
661 "typeSchema": null,
662 "notNull": true,
663 "dimensions": 0,
664 "default": null,
665 "generated": null,
666 "identity": null,
667 "name": "user_id",
668 "entityType": "columns",
669 "schema": "public",
670 "table": "relay_live_activities"
671 },
672 {
673 "type": "varchar(255)",
674 "typeSchema": null,
675 "notNull": true,
676 "dimensions": 0,
677 "default": null,
678 "generated": null,
679 "identity": null,
680 "name": "device_id",
681 "entityType": "columns",
682 "schema": "public",
683 "table": "relay_live_activities"
684 },
685 {
686 "type": "text",
687 "typeSchema": null,
688 "notNull": false,
689 "dimensions": 0,
690 "default": null,
691 "generated": null,
692 "identity": null,
693 "name": "activity_push_token",
694 "entityType": "columns",
695 "schema": "public",
696 "table": "relay_live_activities"
697 },
698 {
699 "type": "varchar(64)",
700 "typeSchema": null,
701 "notNull": false,
702 "dimensions": 0,
703 "default": null,
704 "generated": null,
705 "identity": null,
706 "name": "remote_start_queued_at",
707 "entityType": "columns",
708 "schema": "public",
709 "table": "relay_live_activities"
710 },
711 {
712 "type": "varchar(64)",
713 "typeSchema": null,
714 "notNull": false,
715 "dimensions": 0,
716 "default": null,
717 "generated": null,
718 "identity": null,
719 "name": "remote_started_at",
720 "entityType": "columns",
721 "schema": "public",
722 "table": "relay_live_activities"
723 },
724 {
725 "type": "varchar(64)",
726 "typeSchema": null,
727 "notNull": false,
728 "dimensions": 0,
729 "default": null,
730 "generated": null,
731 "identity": null,
732 "name": "ended_at",
733 "entityType": "columns",
734 "schema": "public",
735 "table": "relay_live_activities"
736 },
737 {
738 "type": "jsonb",
739 "typeSchema": null,
740 "notNull": false,
741 "dimensions": 0,
742 "default": null,
743 "generated": null,
744 "identity": null,
745 "name": "last_aggregate_json",
746 "entityType": "columns",
747 "schema": "public",
748 "table": "relay_live_activities"
749 },
750 {
751 "type": "varchar(64)",
752 "typeSchema": null,
753 "notNull": false,
754 "dimensions": 0,
755 "default": null,
756 "generated": null,
757 "identity": null,
758 "name": "last_live_activity_delivery_at",
759 "entityType": "columns",
760 "schema": "public",
761 "table": "relay_live_activities"
762 },
763 {
764 "type": "varchar(64)",
765 "typeSchema": null,
766 "notNull": true,
767 "dimensions": 0,
768 "default": null,
769 "generated": null,
770 "identity": null,
771 "name": "created_at",
772 "entityType": "columns",
773 "schema": "public",
774 "table": "relay_live_activities"
775 },
776 {
777 "type": "varchar(64)",
778 "typeSchema": null,
779 "notNull": true,
780 "dimensions": 0,
781 "default": null,
782 "generated": null,
783 "identity": null,
784 "name": "updated_at",
785 "entityType": "columns",
786 "schema": "public",
787 "table": "relay_live_activities"
788 },
789 {
790 "type": "varchar(191)",
791 "typeSchema": null,
792 "notNull": true,
793 "dimensions": 0,
794 "default": null,
795 "generated": null,
796 "identity": null,
797 "name": "user_id",
798 "entityType": "columns",
799 "schema": "public",
800 "table": "relay_managed_endpoint_allocations"
801 },
802 {
803 "type": "varchar(191)",
804 "typeSchema": null,
805 "notNull": true,
806 "dimensions": 0,
807 "default": null,
808 "generated": null,
809 "identity": null,
810 "name": "environment_id",
811 "entityType": "columns",
812 "schema": "public",
813 "table": "relay_managed_endpoint_allocations"
814 },
815 {
816 "type": "text",
817 "typeSchema": null,
818 "notNull": true,
819 "dimensions": 0,
820 "default": null,
821 "generated": null,
822 "identity": null,
823 "name": "hostname",
824 "entityType": "columns",
825 "schema": "public",
826 "table": "relay_managed_endpoint_allocations"
827 },
828 {
829 "type": "varchar(191)",
830 "typeSchema": null,
831 "notNull": false,
832 "dimensions": 0,
833 "default": null,
834 "generated": null,
835 "identity": null,
836 "name": "tunnel_id",
837 "entityType": "columns",
838 "schema": "public",
839 "table": "relay_managed_endpoint_allocations"
840 },
841 {
842 "type": "text",
843 "typeSchema": null,
844 "notNull": true,
845 "dimensions": 0,
846 "default": null,
847 "generated": null,
848 "identity": null,
849 "name": "tunnel_name",
850 "entityType": "columns",
851 "schema": "public",
852 "table": "relay_managed_endpoint_allocations"
853 },
854 {
855 "type": "varchar(191)",
856 "typeSchema": null,
857 "notNull": false,
858 "dimensions": 0,
859 "default": null,
860 "generated": null,
861 "identity": null,
862 "name": "dns_record_id",
863 "entityType": "columns",
864 "schema": "public",
865 "table": "relay_managed_endpoint_allocations"
866 },
867 {
868 "type": "varchar(64)",
869 "typeSchema": null,
870 "notNull": false,
871 "dimensions": 0,
872 "default": null,
873 "generated": null,
874 "identity": null,
875 "name": "ready_at",
876 "entityType": "columns",
877 "schema": "public",
878 "table": "relay_managed_endpoint_allocations"
879 },
880 {
881 "type": "varchar(64)",
882 "typeSchema": null,
883 "notNull": false,
884 "dimensions": 0,
885 "default": null,
886 "generated": null,
887 "identity": null,
888 "name": "recovery_enabled_at",
889 "entityType": "columns",
890 "schema": "public",
891 "table": "relay_managed_endpoint_allocations"
892 },
893 {
894 "type": "text",
895 "typeSchema": null,
896 "notNull": false,
897 "dimensions": 0,
898 "default": null,
899 "generated": null,
900 "identity": null,
901 "name": "recovery_environment_public_key",
902 "entityType": "columns",
903 "schema": "public",
904 "table": "relay_managed_endpoint_allocations"
905 },
906 {
907 "type": "varchar(64)",
908 "typeSchema": null,
909 "notNull": false,
910 "dimensions": 0,
911 "default": null,
912 "generated": null,
913 "identity": null,
914 "name": "tunnel_released_at",
915 "entityType": "columns",
916 "schema": "public",
917 "table": "relay_managed_endpoint_allocations"
918 },
919 {
920 "type": "jsonb",
921 "typeSchema": null,
922 "notNull": false,
923 "dimensions": 0,
924 "default": null,
925 "generated": null,
926 "identity": null,
927 "name": "origin",
928 "entityType": "columns",
929 "schema": "public",
930 "table": "relay_managed_endpoint_allocations"
931 },
932 {
933 "type": "integer",
934 "typeSchema": null,
935 "notNull": true,
936 "dimensions": 0,
937 "default": "0",
938 "generated": null,
939 "identity": null,
940 "name": "generation",
941 "entityType": "columns",
942 "schema": "public",
943 "table": "relay_managed_endpoint_allocations"
944 },
945 {
946 "type": "varchar(64)",
947 "typeSchema": null,
948 "notNull": true,
949 "dimensions": 0,
950 "default": null,
951 "generated": null,
952 "identity": null,
953 "name": "created_at",
954 "entityType": "columns",
955 "schema": "public",
956 "table": "relay_managed_endpoint_allocations"
957 },
958 {
959 "type": "varchar(64)",
960 "typeSchema": null,
961 "notNull": true,
962 "dimensions": 0,
963 "default": null,
964 "generated": null,
965 "identity": null,
966 "name": "updated_at",
967 "entityType": "columns",
968 "schema": "public",
969 "table": "relay_managed_endpoint_allocations"
970 },
971 {
972 "type": "varchar(191)",
973 "typeSchema": null,
974 "notNull": true,
975 "dimensions": 0,
976 "default": null,
977 "generated": null,
978 "identity": null,
979 "name": "user_id",
980 "entityType": "columns",
981 "schema": "public",
982 "table": "relay_managed_tunnel_limits"
983 },
984 {
985 "type": "integer",
986 "typeSchema": null,
987 "notNull": true,
988 "dimensions": 0,
989 "default": null,
990 "generated": null,
991 "identity": null,
992 "name": "max_tunnels",
993 "entityType": "columns",
994 "schema": "public",
995 "table": "relay_managed_tunnel_limits"
996 },
997 {
998 "type": "varchar(64)",
999 "typeSchema": null,
1000 "notNull": true,
1001 "dimensions": 0,
1002 "default": null,
1003 "generated": null,
1004 "identity": null,
1005 "name": "created_at",
1006 "entityType": "columns",
1007 "schema": "public",
1008 "table": "relay_managed_tunnel_limits"
1009 },
1010 {
1011 "type": "varchar(64)",
1012 "typeSchema": null,
1013 "notNull": true,
1014 "dimensions": 0,
1015 "default": null,
1016 "generated": null,
1017 "identity": null,
1018 "name": "updated_at",
1019 "entityType": "columns",
1020 "schema": "public",
1021 "table": "relay_managed_tunnel_limits"
1022 },
1023 {
1024 "type": "varchar(255)",
1025 "typeSchema": null,
1026 "notNull": true,
1027 "dimensions": 0,
1028 "default": null,
1029 "generated": null,
1030 "identity": null,
1031 "name": "user_id",
1032 "entityType": "columns",
1033 "schema": "public",
1034 "table": "relay_mobile_devices"
1035 },
1036 {
1037 "type": "varchar(255)",
1038 "typeSchema": null,
1039 "notNull": true,
1040 "dimensions": 0,
1041 "default": null,
1042 "generated": null,
1043 "identity": null,
1044 "name": "device_id",
1045 "entityType": "columns",
1046 "schema": "public",
1047 "table": "relay_mobile_devices"
1048 },
1049 {
1050 "type": "text",
1051 "typeSchema": null,
1052 "notNull": true,
1053 "dimensions": 0,
1054 "default": "'iOS device'",
1055 "generated": null,
1056 "identity": null,
1057 "name": "label",
1058 "entityType": "columns",
1059 "schema": "public",
1060 "table": "relay_mobile_devices"
1061 },
1062 {
1063 "type": "varchar(16)",
1064 "typeSchema": null,
1065 "notNull": true,
1066 "dimensions": 0,
1067 "default": null,
1068 "generated": null,
1069 "identity": null,
1070 "name": "platform",
1071 "entityType": "columns",
1072 "schema": "public",
1073 "table": "relay_mobile_devices"
1074 },
1075 {
1076 "type": "integer",
1077 "typeSchema": null,
1078 "notNull": false,
1079 "dimensions": 0,
1080 "default": null,
1081 "generated": null,
1082 "identity": null,
1083 "name": "ios_major_version",
1084 "entityType": "columns",
1085 "schema": "public",
1086 "table": "relay_mobile_devices"
1087 },
1088 {
1089 "type": "integer",
1090 "typeSchema": null,
1091 "notNull": false,
1092 "dimensions": 0,
1093 "default": null,
1094 "generated": null,
1095 "identity": null,
1096 "name": "android_api_level",
1097 "entityType": "columns",
1098 "schema": "public",
1099 "table": "relay_mobile_devices"
1100 },
1101 {
1102 "type": "varchar(64)",
1103 "typeSchema": null,
1104 "notNull": false,
1105 "dimensions": 0,
1106 "default": null,
1107 "generated": null,
1108 "identity": null,
1109 "name": "app_version",
1110 "entityType": "columns",
1111 "schema": "public",
1112 "table": "relay_mobile_devices"
1113 },
1114 {
1115 "type": "varchar(255)",
1116 "typeSchema": null,
1117 "notNull": false,
1118 "dimensions": 0,
1119 "default": null,
1120 "generated": null,
1121 "identity": null,
1122 "name": "bundle_id",
1123 "entityType": "columns",
1124 "schema": "public",
1125 "table": "relay_mobile_devices"
1126 },
1127 {
1128 "type": "varchar(16)",
1129 "typeSchema": null,
1130 "notNull": false,
1131 "dimensions": 0,
1132 "default": null,
1133 "generated": null,
1134 "identity": null,
1135 "name": "aps_environment",
1136 "entityType": "columns",
1137 "schema": "public",
1138 "table": "relay_mobile_devices"
1139 },
1140 {
1141 "type": "text",
1142 "typeSchema": null,
1143 "notNull": false,
1144 "dimensions": 0,
1145 "default": null,
1146 "generated": null,
1147 "identity": null,
1148 "name": "push_token",
1149 "entityType": "columns",
1150 "schema": "public",
1151 "table": "relay_mobile_devices"
1152 },
1153 {
1154 "type": "text",
1155 "typeSchema": null,
1156 "notNull": false,
1157 "dimensions": 0,
1158 "default": null,
1159 "generated": null,
1160 "identity": null,
1161 "name": "push_to_start_token",
1162 "entityType": "columns",
1163 "schema": "public",
1164 "table": "relay_mobile_devices"
1165 },
1166 {
1167 "type": "jsonb",
1168 "typeSchema": null,
1169 "notNull": true,
1170 "dimensions": 0,
1171 "default": null,
1172 "generated": null,
1173 "identity": null,
1174 "name": "preferences_json",
1175 "entityType": "columns",
1176 "schema": "public",
1177 "table": "relay_mobile_devices"
1178 },
1179 {
1180 "type": "varchar(64)",
1181 "typeSchema": null,
1182 "notNull": true,
1183 "dimensions": 0,
1184 "default": null,
1185 "generated": null,
1186 "identity": null,
1187 "name": "created_at",
1188 "entityType": "columns",
1189 "schema": "public",
1190 "table": "relay_mobile_devices"
1191 },
1192 {
1193 "type": "varchar(64)",
1194 "typeSchema": null,
1195 "notNull": true,
1196 "dimensions": 0,
1197 "default": null,
1198 "generated": null,
1199 "identity": null,
1200 "name": "updated_at",
1201 "entityType": "columns",
1202 "schema": "public",
1203 "table": "relay_mobile_devices"
1204 },
1205 {
1206 "nameExplicit": true,
1207 "columns": [
1208 {
1209 "value": "updated_at",
1210 "isExpression": false,
1211 "asc": true,
1212 "nullsFirst": false,
1213 "opclass": null
1214 }
1215 ],
1216 "isUnique": false,
1217 "where": null,
1218 "with": "",
1219 "method": "btree",
1220 "concurrently": false,
1221 "name": "idx_relay_agent_activity_rows_updated",
1222 "entityType": "indexes",
1223 "schema": "public",
1224 "table": "relay_agent_activity_rows"
1225 },
1226 {
1227 "nameExplicit": true,
1228 "columns": [
1229 {
1230 "value": "environment_id",
1231 "isExpression": false,
1232 "asc": true,
1233 "nullsFirst": false,
1234 "opclass": null
1235 },
1236 {
1237 "value": "thread_id",
1238 "isExpression": false,
1239 "asc": true,
1240 "nullsFirst": false,
1241 "opclass": null
1242 },
1243 {
1244 "value": "created_at",
1245 "isExpression": false,
1246 "asc": true,
1247 "nullsFirst": false,
1248 "opclass": null
1249 }
1250 ],
1251 "isUnique": false,
1252 "where": null,
1253 "with": "",
1254 "method": "btree",
1255 "concurrently": false,
1256 "name": "idx_relay_delivery_attempts_environment",
1257 "entityType": "indexes",
1258 "schema": "public",
1259 "table": "relay_delivery_attempts"
1260 },
1261 {
1262 "nameExplicit": true,
1263 "columns": [
1264 {
1265 "value": "source_job_id",
1266 "isExpression": false,
1267 "asc": true,
1268 "nullsFirst": false,
1269 "opclass": null
1270 }
1271 ],
1272 "isUnique": true,
1273 "where": null,
1274 "with": "",
1275 "method": "btree",
1276 "concurrently": false,
1277 "name": "idx_relay_delivery_attempts_source_job",
1278 "entityType": "indexes",
1279 "schema": "public",
1280 "table": "relay_delivery_attempts"
1281 },
1282 {
1283 "nameExplicit": true,
1284 "columns": [
1285 {
1286 "value": "expires_at",
1287 "isExpression": false,
1288 "asc": true,
1289 "nullsFirst": false,
1290 "opclass": null
1291 }
1292 ],
1293 "isUnique": false,
1294 "where": null,
1295 "with": "",
1296 "method": "btree",
1297 "concurrently": false,
1298 "name": "idx_relay_dpop_proofs_expires_at",
1299 "entityType": "indexes",
1300 "schema": "public",
1301 "table": "relay_dpop_proofs"
1302 },
1303 {
1304 "nameExplicit": true,
1305 "columns": [
1306 {
1307 "value": "credential_hash",
1308 "isExpression": false,
1309 "asc": true,
1310 "nullsFirst": false,
1311 "opclass": null
1312 }
1313 ],
1314 "isUnique": true,
1315 "where": null,
1316 "with": "",
1317 "method": "btree",
1318 "concurrently": false,
1319 "name": "idx_relay_environment_credentials_hash",
1320 "entityType": "indexes",
1321 "schema": "public",
1322 "table": "relay_environment_credentials"
1323 },
1324 {
1325 "nameExplicit": true,
1326 "columns": [
1327 {
1328 "value": "environment_id",
1329 "isExpression": false,
1330 "asc": true,
1331 "nullsFirst": false,
1332 "opclass": null
1333 },
1334 {
1335 "value": "revoked_at",
1336 "isExpression": false,
1337 "asc": true,
1338 "nullsFirst": false,
1339 "opclass": null
1340 }
1341 ],
1342 "isUnique": false,
1343 "where": null,
1344 "with": "",
1345 "method": "btree",
1346 "concurrently": false,
1347 "name": "idx_relay_environment_credentials_environment",
1348 "entityType": "indexes",
1349 "schema": "public",
1350 "table": "relay_environment_credentials"
1351 },
1352 {
1353 "nameExplicit": true,
1354 "columns": [
1355 {
1356 "value": "environment_id",
1357 "isExpression": false,
1358 "asc": true,
1359 "nullsFirst": false,
1360 "opclass": null
1361 },
1362 {
1363 "value": "environment_public_key",
1364 "isExpression": false,
1365 "asc": true,
1366 "nullsFirst": false,
1367 "opclass": null
1368 },
1369 {
1370 "value": "revoked_at",
1371 "isExpression": false,
1372 "asc": true,
1373 "nullsFirst": false,
1374 "opclass": null
1375 }
1376 ],
1377 "isUnique": false,
1378 "where": null,
1379 "with": "",
1380 "method": "btree",
1381 "concurrently": false,
1382 "name": "idx_relay_environment_credentials_environment_key",
1383 "entityType": "indexes",
1384 "schema": "public",
1385 "table": "relay_environment_credentials"
1386 },
1387 {
1388 "nameExplicit": true,
1389 "columns": [
1390 {
1391 "value": "environment_id",
1392 "isExpression": false,
1393 "asc": true,
1394 "nullsFirst": false,
1395 "opclass": null
1396 },
1397 {
1398 "value": "revoked_at",
1399 "isExpression": false,
1400 "asc": true,
1401 "nullsFirst": false,
1402 "opclass": null
1403 }
1404 ],
1405 "isUnique": false,
1406 "where": null,
1407 "with": "",
1408 "method": "btree",
1409 "concurrently": false,
1410 "name": "idx_relay_environment_links_environment",
1411 "entityType": "indexes",
1412 "schema": "public",
1413 "table": "relay_environment_links"
1414 },
1415 {
1416 "nameExplicit": true,
1417 "columns": [
1418 {
1419 "value": "activity_push_token",
1420 "isExpression": false,
1421 "asc": true,
1422 "nullsFirst": false,
1423 "opclass": null
1424 }
1425 ],
1426 "isUnique": true,
1427 "where": null,
1428 "with": "",
1429 "method": "btree",
1430 "concurrently": false,
1431 "name": "idx_relay_live_activities_activity_push_token",
1432 "entityType": "indexes",
1433 "schema": "public",
1434 "table": "relay_live_activities"
1435 },
1436 {
1437 "nameExplicit": true,
1438 "columns": [
1439 {
1440 "value": "hostname",
1441 "isExpression": false,
1442 "asc": true,
1443 "nullsFirst": false,
1444 "opclass": null
1445 }
1446 ],
1447 "isUnique": true,
1448 "where": null,
1449 "with": "",
1450 "method": "btree",
1451 "concurrently": false,
1452 "name": "idx_relay_managed_endpoint_allocations_hostname",
1453 "entityType": "indexes",
1454 "schema": "public",
1455 "table": "relay_managed_endpoint_allocations"
1456 },
1457 {
1458 "nameExplicit": true,
1459 "columns": [
1460 {
1461 "value": "tunnel_name",
1462 "isExpression": false,
1463 "asc": true,
1464 "nullsFirst": false,
1465 "opclass": null
1466 }
1467 ],
1468 "isUnique": true,
1469 "where": null,
1470 "with": "",
1471 "method": "btree",
1472 "concurrently": false,
1473 "name": "idx_relay_managed_endpoint_allocations_tunnel_name",
1474 "entityType": "indexes",
1475 "schema": "public",
1476 "table": "relay_managed_endpoint_allocations"
1477 },
1478 {
1479 "nameExplicit": true,
1480 "columns": [
1481 {
1482 "value": "push_token",
1483 "isExpression": false,
1484 "asc": true,
1485 "nullsFirst": false,
1486 "opclass": null
1487 }
1488 ],
1489 "isUnique": true,
1490 "where": null,
1491 "with": "",
1492 "method": "btree",
1493 "concurrently": false,
1494 "name": "idx_relay_mobile_devices_push_token",
1495 "entityType": "indexes",
1496 "schema": "public",
1497 "table": "relay_mobile_devices"
1498 },
1499 {
1500 "nameExplicit": true,
1501 "columns": [
1502 {
1503 "value": "push_to_start_token",
1504 "isExpression": false,
1505 "asc": true,
1506 "nullsFirst": false,
1507 "opclass": null
1508 }
1509 ],
1510 "isUnique": true,
1511 "where": null,
1512 "with": "",
1513 "method": "btree",
1514 "concurrently": false,
1515 "name": "idx_relay_mobile_devices_push_to_start_token",
1516 "entityType": "indexes",
1517 "schema": "public",
1518 "table": "relay_mobile_devices"
1519 },
1520 {
1521 "columns": ["environment_id", "environment_public_key", "thread_id"],
1522 "nameExplicit": false,
1523 "name": "relay_agent_activity_rows_pkey",
1524 "entityType": "pks",
1525 "schema": "public",
1526 "table": "relay_agent_activity_rows"
1527 },
1528 {
1529 "columns": ["thumbprint", "jti"],
1530 "nameExplicit": false,
1531 "name": "relay_dpop_proofs_pkey",
1532 "entityType": "pks",
1533 "schema": "public",
1534 "table": "relay_dpop_proofs"
1535 },
1536 {
1537 "columns": ["user_id", "environment_id"],
1538 "nameExplicit": false,
1539 "name": "relay_environment_links_pkey",
1540 "entityType": "pks",
1541 "schema": "public",
1542 "table": "relay_environment_links"
1543 },
1544 {
1545 "columns": ["user_id", "device_id"],
1546 "nameExplicit": false,
1547 "name": "relay_live_activities_pkey",
1548 "entityType": "pks",
1549 "schema": "public",
1550 "table": "relay_live_activities"
1551 },
1552 {
1553 "columns": ["user_id", "environment_id"],
1554 "nameExplicit": false,
1555 "name": "relay_managed_endpoint_allocations_pkey",
1556 "entityType": "pks",
1557 "schema": "public",
1558 "table": "relay_managed_endpoint_allocations"
1559 },
1560 {
1561 "columns": ["user_id", "device_id"],
1562 "nameExplicit": false,
1563 "name": "relay_mobile_devices_pkey",
1564 "entityType": "pks",
1565 "schema": "public",
1566 "table": "relay_mobile_devices"
1567 },
1568 {
1569 "columns": ["id"],
1570 "nameExplicit": false,
1571 "name": "relay_delivery_attempts_pkey",
1572 "schema": "public",
1573 "table": "relay_delivery_attempts",
1574 "entityType": "pks"
1575 },
1576 {
1577 "columns": ["credential_id"],
1578 "nameExplicit": false,
1579 "name": "relay_environment_credentials_pkey",
1580 "schema": "public",
1581 "table": "relay_environment_credentials",
1582 "entityType": "pks"
1583 },
1584 {
1585 "columns": ["user_id"],
1586 "nameExplicit": false,
1587 "name": "relay_managed_tunnel_limits_pkey",
1588 "schema": "public",
1589 "table": "relay_managed_tunnel_limits",
1590 "entityType": "pks"
1591 }
1592 ],
1593 "renames": []
1594}
infra/relay/src/environments/EnvironmentConnector.test.ts+33
...@@ -191,6 +191,7 @@ function makeAllocations(...@@ -191,6 +191,7 @@ function makeAllocations(
191 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },191 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
192 updatedAt: "2026-05-25T00:00:00.000Z",192 updatedAt: "2026-05-25T00:00:00.000Z",
193 generation: 1,193 generation: 1,
194 tunnelReleasedAt: null,
194 },195 },
195): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {196): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {
196 return {197 return {
...@@ -480,6 +481,7 @@ describe("EnvironmentConnector", () => {...@@ -480,6 +481,7 @@ describe("EnvironmentConnector", () => {
480 origin: null,481 origin: null,
481 updatedAt: "2026-05-25T00:00:00.000Z",482 updatedAt: "2026-05-25T00:00:00.000Z",
482 generation: 1,483 generation: 1,
484 tunnelReleasedAt: null,
483 }),485 }),
484 }),486 }),
485 ),487 ),
...@@ -550,9 +552,40 @@ describe("EnvironmentConnector", () => {...@@ -550,9 +552,40 @@ describe("EnvironmentConnector", () => {
550 error: "Managed endpoint health request failed: Environment is unavailable.",552 error: "Managed endpoint health request failed: Environment is unavailable.",
551 traceId: expect.any(String),553 traceId: expect.any(String),
552 });554 });
555 expect(result).not.toHaveProperty("offlineReason");
553 }).pipe(Effect.provide(layerConnectorTest(execute)));556 }).pipe(Effect.provide(layerConnectorTest(execute)));
554 });557 });
555558
559 it.effect("reports a released tunnel as the reason an environment is offline", () => {
560 const execute = (request: HttpClientRequest.HttpClientRequest) =>
561 Effect.succeed(
562 HttpClientResponse.fromWeb(request, new Response("tunnel not found", { status: 530 })),
563 );
564 const allocations = makeAllocations({
565 userId: "user_123",
566 environmentId: "env-connector-test",
567 hostname: "env.example.test",
568 tunnelId: "tunnel-id",
569 tunnelName: "tunnel-name",
570 dnsRecordId: "dns-record-id",
571 readyAt: "2026-05-25T00:00:00.000Z",
572 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
573 updatedAt: "2026-05-25T00:00:00.000Z",
574 generation: 2,
575 tunnelReleasedAt: "2026-05-26T00:00:00.000Z",
576 });
577
578 return Effect.gen(function* () {
579 const connector = yield* EnvironmentConnector.EnvironmentConnector;
580 const result = yield* connector.status({
581 userId: "user_123",
582 environmentId: "env-connector-test",
583 });
584
585 expect(result).toMatchObject({ status: "offline", offlineReason: "tunnel_released" });
586 }).pipe(Effect.provide(layerConnectorTest(execute, { allocations })));
587 });
588
556 it.effect("rejects health responses with a mismatched top-level environment id", () => {589 it.effect("rejects health responses with a mismatched top-level environment id", () => {
557 const execute = (request: HttpClientRequest.HttpClientRequest) =>590 const execute = (request: HttpClientRequest.HttpClientRequest) =>
558 Effect.sync(() => {591 Effect.sync(() => {
infra/relay/src/environments/EnvironmentConnector.ts+12
...@@ -163,6 +163,16 @@ const isEnvironmentHealthError = Schema.is(...@@ -163,6 +163,16 @@ const isEnvironmentHealthError = Schema.is(
163 ]),163 ]),
164);164);
165165
166/**
167 * Cleanup deleted this environment's tunnel and nothing has recorded a new
168 * one, so the host is unreachable until it updates and requests recovery.
169 */
170function offlineReasonFor(
171 allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null,
172): { readonly offlineReason?: "tunnel_released" } {
173 return allocation?.tunnelReleasedAt ? { offlineReason: "tunnel_released" } : {};
174}
175
166function environmentHealthRequestFailureMessage(cause: unknown): string {176function environmentHealthRequestFailureMessage(cause: unknown): string {
167 return isEnvironmentHealthError(cause)177 return isEnvironmentHealthError(cause)
168 ? `Managed endpoint health request failed: ${cause.message}`178 ? `Managed endpoint health request failed: ${cause.message}`
...@@ -510,6 +520,7 @@ const make = Effect.gen(function* () {...@@ -510,6 +520,7 @@ const make = Effect.gen(function* () {
510 status: "offline" as const,520 status: "offline" as const,
511 checkedAt,521 checkedAt,
512 error: "Managed endpoint health request timed out.",522 error: "Managed endpoint health request timed out.",
523 ...offlineReasonFor(allocation),
513 traceId,524 traceId,
514 };525 };
515 }526 }
...@@ -532,6 +543,7 @@ const make = Effect.gen(function* () {...@@ -532,6 +543,7 @@ const make = Effect.gen(function* () {
532 status: "offline" as const,543 status: "offline" as const,
533 checkedAt,544 checkedAt,
534 error: environmentHealthRequestFailureMessage(responseOption.value.cause),545 error: environmentHealthRequestFailureMessage(responseOption.value.cause),
546 ...offlineReasonFor(allocation),
535 traceId,547 traceId,
536 };548 };
537 }549 }
infra/relay/src/environments/ManagedEndpointAllocations.test.ts+36
...@@ -19,6 +19,7 @@ describe("ManagedEndpointAllocations", () => {...@@ -19,6 +19,7 @@ describe("ManagedEndpointAllocations", () => {
19 readonly readyAt: unknown;19 readonly readyAt: unknown;
20 readonly recoveryEnabledAt: unknown;20 readonly recoveryEnabledAt: unknown;
21 readonly recoveryEnvironmentPublicKey: unknown;21 readonly recoveryEnvironmentPublicKey: unknown;
22 readonly tunnelReleasedAt: unknown;
22 }23 }
23 | undefined;24 | undefined;
24 const fakeDb = {25 const fakeDb = {
...@@ -30,6 +31,7 @@ describe("ManagedEndpointAllocations", () => {...@@ -30,6 +31,7 @@ describe("ManagedEndpointAllocations", () => {
30 readonly readyAt: unknown;31 readonly readyAt: unknown;
31 readonly recoveryEnabledAt: unknown;32 readonly recoveryEnabledAt: unknown;
32 readonly recoveryEnvironmentPublicKey: unknown;33 readonly recoveryEnvironmentPublicKey: unknown;
34 readonly tunnelReleasedAt: unknown;
33 }) => {35 }) => {
34 updated = values;36 updated = values;
35 return {37 return {
...@@ -53,6 +55,8 @@ describe("ManagedEndpointAllocations", () => {...@@ -53,6 +55,8 @@ describe("ManagedEndpointAllocations", () => {
53 }),55 }),
54 ).toBe(8);56 ).toBe(8);
55 expect(updated?.tunnelId).toBe("replacement-tunnel");57 expect(updated?.tunnelId).toBe("replacement-tunnel");
58 // Any recorded tunnel ends a released state, so status stops reporting it.
59 expect(updated?.tunnelReleasedAt).toBeNull();
56 const query = new PgDialect().sqlToQuery(updated?.readyAt as never);60 const query = new PgDialect().sqlToQuery(updated?.readyAt as never);
57 expect(query.sql).toBe(61 expect(query.sql).toBe(
58 'case when "relay_managed_endpoint_allocations"."tunnel_id" = $1 then "relay_managed_endpoint_allocations"."ready_at" else null end',62 'case when "relay_managed_endpoint_allocations"."tunnel_id" = $1 then "relay_managed_endpoint_allocations"."ready_at" else null end',
...@@ -250,6 +254,38 @@ describe("ManagedEndpointAllocations", () => {...@@ -250,6 +254,38 @@ describe("ManagedEndpointAllocations", () => {
250 }).pipe(Effect.provide(layerWithDb(fakeDb)));254 }).pipe(Effect.provide(layerWithDb(fakeDb)));
251 });255 });
252256
257 it.effect.each([
258 { markReleased: true, released: true },
259 { markReleased: undefined, released: false },
260 ] as const)(
261 "marks the tunnel released only for the claim that deletes it ($markReleased)",
262 ({ markReleased, released }) => {
263 let updated: Record<string, unknown> | undefined;
264 const fakeDb = {
265 update: () => ({
266 set: (values: Record<string, unknown>) => {
267 updated = values;
268 return {
269 where: () => ({ returning: () => Effect.succeed([{ generation: 4 }]) }),
270 };
271 },
272 }),
273 } as unknown as RelayDb.RelayDb["Service"];
274
275 return Effect.gen(function* () {
276 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
277 yield* allocations.claimRelease({
278 userId: "user-1",
279 environmentId: "environment-1",
280 tunnelId: "tunnel-1",
281 generation: 3,
282 ...(markReleased === undefined ? {} : { markReleased }),
283 });
284 expect(typeof updated?.tunnelReleasedAt === "string").toBe(released);
285 }).pipe(Effect.provide(layerWithDb(fakeDb)));
286 },
287 );
288
253 it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {289 it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {
254 const fakeDb = {290 const fakeDb = {
255 update: (table: unknown) => {291 update: (table: unknown) => {
infra/relay/src/environments/ManagedEndpointAllocations.ts+9-1
...@@ -24,6 +24,8 @@ export interface ManagedEndpointAllocation {...@@ -24,6 +24,8 @@ export interface ManagedEndpointAllocation {
24 readonly origin: RelayManagedEndpointOrigin | null;24 readonly origin: RelayManagedEndpointOrigin | null;
25 readonly updatedAt: string;25 readonly updatedAt: string;
26 readonly generation: number;26 readonly generation: number;
27 /** When cleanup deleted the recorded tunnel; null once a tunnel is recorded again. */
28 readonly tunnelReleasedAt: string | null;
27}29}
2830
29export interface ManagedEndpointTunnelAllocation extends ManagedEndpointAllocation {31export interface ManagedEndpointTunnelAllocation extends ManagedEndpointAllocation {
...@@ -111,6 +113,8 @@ interface MarkManagedEndpointReadyInput extends ManagedEndpointAllocationKey {...@@ -111,6 +113,8 @@ interface MarkManagedEndpointReadyInput extends ManagedEndpointAllocationKey {
111interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {113interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
112 readonly tunnelId: string;114 readonly tunnelId: string;
113 readonly generation: number;115 readonly generation: number;
116 /** Record that the tunnel is being deleted; set only by the claim that deletes it. */
117 readonly markReleased?: boolean;
114}118}
115119
116interface EnableManagedEndpointRecoveryInput extends ManagedEndpointAllocationKey {120interface EnableManagedEndpointRecoveryInput extends ManagedEndpointAllocationKey {
...@@ -202,6 +206,7 @@ const allocationSelection = {...@@ -202,6 +206,7 @@ const allocationSelection = {
202 origin: relayManagedEndpointAllocations.origin,206 origin: relayManagedEndpointAllocations.origin,
203 updatedAt: relayManagedEndpointAllocations.updatedAt,207 updatedAt: relayManagedEndpointAllocations.updatedAt,
204 generation: relayManagedEndpointAllocations.generation,208 generation: relayManagedEndpointAllocations.generation,
209 tunnelReleasedAt: relayManagedEndpointAllocations.tunnelReleasedAt,
205};210};
206211
207const whereAllocation = (input: ManagedEndpointAllocationKey) =>212const whereAllocation = (input: ManagedEndpointAllocationKey) =>
...@@ -326,6 +331,7 @@ export const make = Effect.gen(function* () {...@@ -326,6 +331,7 @@ export const make = Effect.gen(function* () {
326 // again before the reaper may treat it as recoverable.331 // again before the reaper may treat it as recoverable.
327 recoveryEnabledAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnabledAt} else null end`,332 recoveryEnabledAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnabledAt} else null end`,
328 recoveryEnvironmentPublicKey: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnvironmentPublicKey} else null end`,333 recoveryEnvironmentPublicKey: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnvironmentPublicKey} else null end`,
334 tunnelReleasedAt: null,
329 updatedAt: DateTime.formatIso(yield* DateTime.now),335 updatedAt: DateTime.formatIso(yield* DateTime.now),
330 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,336 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
331 })337 })
...@@ -534,11 +540,13 @@ export const make = Effect.gen(function* () {...@@ -534,11 +540,13 @@ export const make = Effect.gen(function* () {
534 claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (540 claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
535 input: ClaimManagedEndpointReleaseInput,541 input: ClaimManagedEndpointReleaseInput,
536 ) {542 ) {
543 const now = DateTime.formatIso(yield* DateTime.now);
537 const claimed = yield* db544 const claimed = yield* db
538 .update(relayManagedEndpointAllocations)545 .update(relayManagedEndpointAllocations)
539 .set({546 .set({
540 updatedAt: DateTime.formatIso(yield* DateTime.now),547 updatedAt: now,
541 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,548 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
549 ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}),
542 })550 })
543 .where(551 .where(
544 and(552 and(
infra/relay/src/environments/ManagedEndpointProvider.test.ts+80
...@@ -221,6 +221,7 @@ function makeAllocations(calls: AllocationCall[] = []) {...@@ -221,6 +221,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
221 origin: null,221 origin: null,
222 updatedAt: `generation-${++generation}`,222 updatedAt: `generation-${++generation}`,
223 generation: 0,223 generation: 0,
224 tunnelReleasedAt: null,
224 };225 };
225 allocations.set(allocationKey(input), allocation);226 allocations.set(allocationKey(input), allocation);
226 return allocation;227 return allocation;
...@@ -968,6 +969,38 @@ describe("ManagedEndpointProvider", () => {...@@ -968,6 +969,38 @@ describe("ManagedEndpointProvider", () => {
968 "updateRecord",969 "updateRecord",
969 ]);970 ]);
970 expect(allocationCalls.map((call) => call.operation)).not.toContain("remove");971 expect(allocationCalls.map((call) => call.operation)).not.toContain("remove");
972 // An ordinary release, such as a host shutting down, must not tell the
973 // user to update: that host gets a new tunnel when it starts again.
974 const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
975 expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
976 [undefined, undefined],
977 );
978 }).pipe(Effect.provide(layer));
979 });
980
981 it.effect("marks the release on the claim that deletes the tunnel when asked", () => {
982 const allocationCalls: AllocationCall[] = [];
983 const layer = layerProvider(
984 makePersistentTunnelClient(),
985 makeDnsClient(),
986 makeAllocations(allocationCalls),
987 );
988
989 return Effect.gen(function* () {
990 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
991 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
992 yield* provider.provision({
993 ...key,
994 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
995 });
996 expect(yield* provider.release({ ...key, markReleased: true })).toBe(true);
997
998 // The first claim only reserves the release; the second, taken with the
999 // row locked right before the delete, is the one that records it.
1000 const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
1001 expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
1002 [undefined, true],
1003 );
971 }).pipe(Effect.provide(layer));1004 }).pipe(Effect.provide(layer));
972 });1005 });
9731006
...@@ -1502,6 +1535,53 @@ describe("ManagedEndpointProvider", () => {...@@ -1502,6 +1535,53 @@ describe("ManagedEndpointProvider", () => {
1502 }).pipe(Effect.provide(layer));1535 }).pipe(Effect.provide(layer));
1503 });1536 });
15041537
1538 it.effect.each([
1539 { name: "the tunnel is gone", tunnelRemoved: true, released: true },
1540 { name: "the tunnel still exists", tunnelRemoved: false, released: false },
1541 ] as const)(
1542 "resolves a delete whose response was lost by checking whether $name",
1543 ({ tunnelRemoved, released }) => {
1544 const persistent = makePersistentTunnelClient();
1545 const lostResponse = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
1546 operation: "delete",
1547 tunnelId: "tunnel-id",
1548 cause: { _tag: "TimeoutError" },
1549 });
1550 // Cloudflare may or may not have applied the delete before the
1551 // response was lost; the client only sees the timeout.
1552 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
1553 ...persistent,
1554 delete: (tunnelId) =>
1555 (tunnelRemoved ? persistent.delete(tunnelId) : Effect.void).pipe(
1556 Effect.andThen(Effect.fail(lostResponse)),
1557 ),
1558 });
1559 const allocationCalls: AllocationCall[] = [];
1560 const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls));
1561
1562 return Effect.gen(function* () {
1563 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
1564 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
1565 yield* provider.provision({
1566 ...key,
1567 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
1568 });
1569 const result = yield* Effect.result(provider.release({ ...key, markReleased: true }));
1570
1571 if (released) {
1572 // Succeeding commits the marked claim, so status can explain it.
1573 expect(result).toMatchObject({ _tag: "Success", success: true });
1574 } else {
1575 // Failing rolls the claim back; the tunnel is still there to retry.
1576 expect(result).toMatchObject({
1577 _tag: "Failure",
1578 failure: { stage: "delete-tunnel", cause: lostResponse },
1579 });
1580 }
1581 }).pipe(Effect.provide(layer));
1582 },
1583 );
1584
1505 it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => {1585 it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => {
1506 const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({1586 const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
1507 operation: "delete",1587 operation: "delete",
infra/relay/src/environments/ManagedEndpointProvider.ts+28
...@@ -193,6 +193,11 @@ export class ManagedEndpointProvider extends Context.Service<...@@ -193,6 +193,11 @@ export class ManagedEndpointProvider extends Context.Service<
193 readonly expectedTunnelId?: string;193 readonly expectedTunnelId?: string;
194 readonly expectedInactiveBefore?: string;194 readonly expectedInactiveBefore?: string;
195 readonly expectedStatus?: "inactive" | "down";195 readonly expectedStatus?: "inactive" | "down";
196 /**
197 * Record that cleanup removed a legacy host's tunnel, so status tells the
198 * user to update. Other releases leave hosts that recover on their own.
199 */
200 readonly markReleased?: boolean;
196 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;201 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;
197 }202 }
198>()("t3code-relay/environments/ManagedEndpointProvider") {}203>()("t3code-relay/environments/ManagedEndpointProvider") {}
...@@ -808,6 +813,19 @@ export const make = Effect.gen(function* () {...@@ -808,6 +813,19 @@ export const make = Effect.gen(function* () {
808 if (claimedGeneration === null) {813 if (claimedGeneration === null) {
809 return false;814 return false;
810 }815 }
816 // After a failed delete: succeed if the tunnel is gone, since the delete
817 // then took effect and only its response was lost; otherwise keep the
818 // delete's error.
819 const confirmTunnelGone = (
820 failure: ManagedEndpointDeprovisioningFailed,
821 ): Effect.Effect<void, ManagedEndpointDeprovisioningFailed> =>
822 tunnels.get(tunnelId).pipe(
823 Effect.andThen(Effect.fail(failure)),
824 Effect.catchTags({
825 ManagedEndpointTunnelClientError: (lookupFailure) =>
826 isManagedEndpointNotFound(lookupFailure.cause) ? Effect.void : Effect.fail(failure),
827 }),
828 );
811 const deleteTunnel = ignoreNotFound(tunnels.delete(tunnelId)).pipe(829 const deleteTunnel = ignoreNotFound(tunnels.delete(tunnelId)).pipe(
812 Effect.mapError(830 Effect.mapError(
813 (cause) =>831 (cause) =>
...@@ -877,6 +895,7 @@ export const make = Effect.gen(function* () {...@@ -877,6 +895,7 @@ export const make = Effect.gen(function* () {
877 environmentId: input.environmentId,895 environmentId: input.environmentId,
878 tunnelId,896 tunnelId,
879 generation: claimedGeneration,897 generation: claimedGeneration,
898 ...(input.markReleased === true ? { markReleased: true } : {}),
880 })899 })
881 .pipe(900 .pipe(
882 Effect.mapError(901 Effect.mapError(
...@@ -895,12 +914,21 @@ export const make = Effect.gen(function* () {...@@ -895,12 +914,21 @@ export const make = Effect.gen(function* () {
895 // A connector still attached means the tunnel is not released. That914 // A connector still attached means the tunnel is not released. That
896 // is the same answer as losing the claim: the caller keeps its config,915 // is the same answer as losing the claim: the caller keeps its config,
897 // and the reaper deletes the tunnel once it has been down long enough.916 // and the reaper deletes the tunnel once it has been down long enough.
917 // A delete whose response is lost (a timeout) may still have
918 // removed the tunnel. Ask Cloudflare before rolling back: if the
919 // tunnel is gone, the delete happened and the claim, including any
920 // released marker, must commit, since no later sweep can find
921 // this tunnel again to retry.
898 return yield* deleteTunnel.pipe(922 return yield* deleteTunnel.pipe(
899 Effect.as(true),923 Effect.as(true),
900 Effect.catchIf(924 Effect.catchIf(
901 (error) => isManagedEndpointTunnelInUse(error.cause),925 (error) => isManagedEndpointTunnelInUse(error.cause),
902 () => Effect.succeed(false),926 () => Effect.succeed(false),
903 ),927 ),
928 Effect.catchTags({
929 ManagedEndpointDeprovisioningFailed: (failure) =>
930 confirmTunnelGone(failure).pipe(Effect.as(true)),
931 }),
904 );932 );
905 }),933 }),
906 )934 )
infra/relay/src/environments/ManagedEndpointReaper.test.ts+5
...@@ -55,6 +55,7 @@ function allocation(input: {...@@ -55,6 +55,7 @@ function allocation(input: {
55 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },55 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
56 updatedAt: "2026-08-25T11:00:00.000Z",56 updatedAt: "2026-08-25T11:00:00.000Z",
57 generation: 1,57 generation: 1,
58 tunnelReleasedAt: null,
58 recoveryEnabled: input.recoveryEnabled,59 recoveryEnabled: input.recoveryEnabled,
59 };60 };
60}61}
...@@ -296,6 +297,8 @@ describe("ManagedEndpointReaper", () => {...@@ -296,6 +297,8 @@ describe("ManagedEndpointReaper", () => {
296 failed: 0,297 failed: 0,
297 });298 });
298 expect(state.deleted).toEqual(["down-1", "inactive-1"]);299 expect(state.deleted).toEqual(["down-1", "inactive-1"]);
300 // A host that registered recovery gets a new tunnel on its own.
301 expect(state.releases.some((release) => release.markReleased === true)).toBe(false);
299 expect(state.releases.map((request) => request.expectedTunnelId)).toEqual([302 expect(state.releases.map((request) => request.expectedTunnelId)).toEqual([
300 "down-1",303 "down-1",
301 "inactive-1",304 "inactive-1",
...@@ -582,6 +585,8 @@ describe("ManagedEndpointReaper", () => {...@@ -582,6 +585,8 @@ describe("ManagedEndpointReaper", () => {
582 deleted: 2,585 deleted: 2,
583 });586 });
584 expect([...state.deleted].sort()).toEqual(["legacy-never", "legacy-old"]);587 expect([...state.deleted].sort()).toEqual(["legacy-never", "legacy-old"]);
588 // A legacy host needs an update to recover, so its user is told why.
589 expect(state.releases.every((release) => release.markReleased === true)).toBe(true);
585 // The release re-checks each tunnel against the 7-day cutoff, not590 // The release re-checks each tunnel against the 7-day cutoff, not
586 // the 5-minute cutoff used for recoverable tunnels.591 // the 5-minute cutoff used for recoverable tunnels.
587 for (const release of state.releases) {592 for (const release of state.releases) {
infra/relay/src/environments/ManagedEndpointReaper.ts+2
...@@ -386,6 +386,8 @@ export const make = Effect.gen(function* () {...@@ -386,6 +386,8 @@ export const make = Effect.gen(function* () {
386 expectedTunnelId: candidate.tunnel.id,386 expectedTunnelId: candidate.tunnel.id,
387 expectedInactiveBefore: candidate.inactiveBefore,387 expectedInactiveBefore: candidate.inactiveBefore,
388 expectedStatus: candidate.status,388 expectedStatus: candidate.status,
389 // Only a legacy host needs an update to recover; tell its user so.
390 ...(candidate.legacy ? { markReleased: true } : {}),
389 })391 })
390 .pipe(Effect.result);392 .pipe(Effect.result);
391 if (result._tag === "Failure") {393 if (result._tag === "Failure") {
infra/relay/src/hooks/HeldHooks.test.ts+1
...@@ -31,6 +31,7 @@ const allocationFor = (...@@ -31,6 +31,7 @@ const allocationFor = (
31 tunnelName: `t3coderelay-managedendpoint-dev-${key}`,31 tunnelName: `t3coderelay-managedendpoint-dev-${key}`,
32 dnsRecordId: "dns-record-id",32 dnsRecordId: "dns-record-id",
33 readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,33 readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,
34 tunnelReleasedAt: null,
34 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },35 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
35 updatedAt: "2026-05-25T00:00:00.000Z",36 updatedAt: "2026-05-25T00:00:00.000Z",
36 generation: 1,37 generation: 1,
infra/relay/src/hooks/HookForwarder.test.ts+1
...@@ -66,6 +66,7 @@ const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {...@@ -66,6 +66,7 @@ const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
66 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,66 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
67 dnsRecordId: "dns-record-id",67 dnsRecordId: "dns-record-id",
68 readyAt: "2026-05-25T00:00:00.000Z",68 readyAt: "2026-05-25T00:00:00.000Z",
69 tunnelReleasedAt: null,
69 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },70 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
70 updatedAt: "2026-05-25T00:00:00.000Z",71 updatedAt: "2026-05-25T00:00:00.000Z",
71 generation: 1,72 generation: 1,
infra/relay/src/http/Api.test.ts+4
...@@ -822,6 +822,7 @@ describe("relay managed tunnel recovery", () => {...@@ -822,6 +822,7 @@ describe("relay managed tunnel recovery", () => {
822 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },822 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
823 updatedAt: "replacement-generation",823 updatedAt: "replacement-generation",
824 generation: 3,824 generation: 3,
825 tunnelReleasedAt: null,
825 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;826 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
826827
827 return Effect.gen(function* () {828 return Effect.gen(function* () {
...@@ -924,6 +925,7 @@ describe("relay environment unlink", () => {...@@ -924,6 +925,7 @@ describe("relay environment unlink", () => {
924 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,925 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
925 dnsRecordId: "dns-1",926 dnsRecordId: "dns-1",
926 readyAt: "2026-07-28T00:00:00.000Z",927 readyAt: "2026-07-28T00:00:00.000Z",
928 tunnelReleasedAt: null,
927 origin: null,929 origin: null,
928 updatedAt: "2026-07-28T00:00:00.000Z",930 updatedAt: "2026-07-28T00:00:00.000Z",
929 generation: 1,931 generation: 1,
...@@ -962,6 +964,7 @@ describe("relay environment unlink", () => {...@@ -962,6 +964,7 @@ describe("relay environment unlink", () => {
962 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },964 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
963 updatedAt: "generation-before-unlink",965 updatedAt: "generation-before-unlink",
964 generation: 1,966 generation: 1,
967 tunnelReleasedAt: null,
965 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;968 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
966969
967 return Effect.gen(function* () {970 return Effect.gen(function* () {
...@@ -1108,6 +1111,7 @@ describe("relay environment unlink", () => {...@@ -1108,6 +1111,7 @@ describe("relay environment unlink", () => {
1108 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },1111 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
1109 updatedAt: "original-generation",1112 updatedAt: "original-generation",
1110 generation: 1,1113 generation: 1,
1114 tunnelReleasedAt: null,
1111 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;1115 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
11121116
1113 return Effect.gen(function* () {1117 return Effect.gen(function* () {
infra/relay/src/persistence/schema.ts+2
...@@ -99,6 +99,8 @@ export const relayManagedEndpointAllocations = pgTable(...@@ -99,6 +99,8 @@ export const relayManagedEndpointAllocations = pgTable(
99 readyAt: varchar("ready_at", { length: 64 }),99 readyAt: varchar("ready_at", { length: 64 }),
100 recoveryEnabledAt: varchar("recovery_enabled_at", { length: 64 }),100 recoveryEnabledAt: varchar("recovery_enabled_at", { length: 64 }),
101 recoveryEnvironmentPublicKey: text("recovery_environment_public_key"),101 recoveryEnvironmentPublicKey: text("recovery_environment_public_key"),
102 // Set when cleanup deletes the recorded tunnel; cleared when a tunnel is recorded again.
103 tunnelReleasedAt: varchar("tunnel_released_at", { length: 64 }),
102 origin: jsonb("origin").$type<RelayManagedEndpointOrigin>(),104 origin: jsonb("origin").$type<RelayManagedEndpointOrigin>(),
103 generation: integer("generation").notNull().default(0),105 generation: integer("generation").notNull().default(0),
104 createdAt: varchar("created_at", { length: 64 }).notNull(),106 createdAt: varchar("created_at", { length: 64 }).notNull(),
packages/client-runtime/src/relay/errorPresentation.ts+19-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { DpopFailureReason } from "@t3tools/contracts";1import type { DpopFailureReason } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";2import type { RelayEnvironmentStatusResponse, RelayProtectedError } from "@t3tools/contracts/relay";
33
4export const DPOP_CLOCK_HINT =4export const DPOP_CLOCK_HINT =
5 "Hint: Check that automatic date and time is enabled on both devices, then try again.";5 "Hint: Check that automatic date and time is enabled on both devices, then try again.";
...@@ -64,3 +64,21 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {...@@ -64,3 +64,21 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
64 return `Relay encountered an internal error (${error.reason}).`;64 return `Relay encountered an internal error (${error.reason}).`;
65 }65 }
66}66}
67
68// A host with a current build gets a new tunnel on its own within minutes of
69// coming back, which clears this reason. While it is still reported, the host
70// is either still off or running a build too old to do that.
71export const RELAY_TUNNEL_RELEASED_MESSAGE =
72 "Offline for a while, so its T3 Connect tunnel was removed. Start T3 Code on that computer and update it to the latest version to reconnect.";
73
74/** User-facing text for an offline status, or null when the relay gave no known reason. */
75export function relayOfflineReasonMessage(
76 status: Pick<RelayEnvironmentStatusResponse, "offlineReason">,
77): string | null {
78 switch (status.offlineReason) {
79 case "tunnel_released":
80 return RELAY_TUNNEL_RELEASED_MESSAGE;
81 case undefined:
82 return null;
83 }
84}
packages/contracts/src/relay.ts+9
...@@ -823,6 +823,14 @@ export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResp...@@ -823,6 +823,14 @@ export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResp
823export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]);823export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]);
824export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type;824export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type;
825825
826/**
827 * Why an environment is offline, when the relay knows more than "the host
828 * did not answer". `tunnel_released`: the relay deleted the environment's
829 * idle tunnel, and the host needs a current T3 Code build to get a new one.
830 */
831const RelayEnvironmentOfflineReason = Schema.Literals(["tunnel_released"]);
832type RelayEnvironmentOfflineReason = typeof RelayEnvironmentOfflineReason.Type;
833
826export const RelayEnvironmentStatusResponse = Schema.Struct({834export const RelayEnvironmentStatusResponse = Schema.Struct({
827 environmentId: EnvironmentId,835 environmentId: EnvironmentId,
828 endpoint: RelayManagedEndpoint,836 endpoint: RelayManagedEndpoint,
...@@ -830,6 +838,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({...@@ -830,6 +838,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({
830 checkedAt: TrimmedNonEmptyString,838 checkedAt: TrimmedNonEmptyString,
831 descriptor: Schema.optional(ExecutionEnvironmentDescriptor),839 descriptor: Schema.optional(ExecutionEnvironmentDescriptor),
832 error: Schema.optional(TrimmedNonEmptyString),840 error: Schema.optional(TrimmedNonEmptyString),
841 offlineReason: Schema.optional(RelayEnvironmentOfflineReason),
833 traceId: Schema.optional(TrimmedNonEmptyString),842 traceId: Schema.optional(TrimmedNonEmptyString),
834});843});
835export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;844export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;