authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 17:29:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 17:29:26-07:00
log935c55b3778fdeae0e25b250ce2a9fa7e79c0327
tree45b47fe43266a9df2c0a186724a2ccb2a7191fba
parent7a368fe7c45a45c284f1b064e440a2d696452b07
signature Signed by PGP key B5690EEEBB952194

fix(clients): disable incompatible environments during discovery (#11990)


17 files changed, 636 insertions(+), 33 deletions(-)

apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts+27-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
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
...@@ -24,6 +24,32 @@ function relayStatus(...@@ -24,6 +24,32 @@ function relayStatus(
24}24}
2525
26describe("available cloud environment presentation", () => {26describe("available cloud environment presentation", () => {
27 it("shows an incompatible discovered server before any connection attempt", () => {
28 const onlineStatus = relayStatus("online");
29 const status = {
30 ...onlineStatus,
31 descriptor: {
32 environmentId: onlineStatus.environmentId,
33 label: "Preview server",
34 platform: { os: "darwin", arch: "arm64" },
35 serverVersion: "2.0.0",
36 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1,
37 capabilities: { repositoryIdentity: true },
38 },
39 } satisfies RelayEnvironmentStatusResponse;
40 expect(
41 availableCloudEnvironmentPresentation({
42 isStatusPending: false,
43 status,
44 statusError: null,
45 statusErrorTraceId: null,
46 }),
47 ).toMatchObject({
48 connectionState: "unsupported",
49 statusText: "Client not supported",
50 });
51 });
52
27 it("presents an online unsaved environment as available, not connected", () => {53 it("presents an online unsaved environment as available, not connected", () => {
28 expect(54 expect(
29 availableCloudEnvironmentPresentation({55 availableCloudEnvironmentPresentation({
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts+16-1
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";1import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
2import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";2import {
3 orchestrationProtocolCompatibilityError,
4 type EnvironmentConnectionPhase,
5} from "@t3tools/client-runtime/connection";
36
4export interface AvailableCloudEnvironmentPresentation {7export interface AvailableCloudEnvironmentPresentation {
5 readonly connectionError: string | null;8 readonly connectionError: string | null;
...@@ -14,6 +17,18 @@ export function availableCloudEnvironmentPresentation(input: {...@@ -14,6 +17,18 @@ export function availableCloudEnvironmentPresentation(input: {
14 readonly statusError: string | null;17 readonly statusError: string | null;
15 readonly statusErrorTraceId: string | null;18 readonly statusErrorTraceId: string | null;
16}): AvailableCloudEnvironmentPresentation {19}): AvailableCloudEnvironmentPresentation {
20 const compatibilityError =
21 input.status?.descriptor === undefined
22 ? null
23 : orchestrationProtocolCompatibilityError(input.status.descriptor);
24 if (compatibilityError !== null) {
25 return {
26 connectionError: compatibilityError.message,
27 connectionErrorTraceId: null,
28 connectionState: "unsupported",
29 statusText: "Client not supported",
30 };
31 }
17 if (input.status?.status === "online") {32 if (input.status?.status === "online") {
18 return {33 return {
19 connectionError: null,34 connectionError: null,
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+7-4
...@@ -219,7 +219,8 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -219,7 +219,8 @@ function ConnectedCloudEnvironmentRow(props: {
219 const serverConfig = useAtomValue(219 const serverConfig = useAtomValue(
220 serverEnvironment.configValueAtom(props.environment.environmentId),220 serverEnvironment.configValueAtom(props.environment.environmentId),
221 );221 );
222 const enabled = props.environment.isEnabled;222 const unsupported = props.environment.connectionState === "unsupported";
223 const enabled = props.environment.isEnabled && !unsupported;
223 return (224 return (
224 <Pressable225 <Pressable
225 accessibilityHint="Long press to remove from this device"226 accessibilityHint="Long press to remove from this device"
...@@ -227,15 +228,16 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -227,15 +228,16 @@ function ConnectedCloudEnvironmentRow(props: {
227 >228 >
228 <CloudEnvironmentRowShell229 <CloudEnvironmentRowShell
229 borderTop={props.borderTop}230 borderTop={props.borderTop}
230 connectionError={enabled ? props.environment.connectionError : null}231 connectionError={enabled || unsupported ? props.environment.connectionError : null}
231 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}232 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
232 connectionState={enabled ? props.environment.connectionState : "available"}233 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
233 errorExpanded={props.errorExpanded}234 errorExpanded={props.errorExpanded}
234 label={props.environment.environmentLabel}235 label={props.environment.environmentLabel}
235 machine={resolveEnvironmentMachineKind(serverConfig)}236 machine={resolveEnvironmentMachineKind(serverConfig)}
236 onValueChange={props.onSetEnabled}237 onValueChange={props.onSetEnabled}
237 onToggleError={props.onToggleError}238 onToggleError={props.onToggleError}
238 {...(enabled ? {} : { statusText: "Off" })}239 disabled={unsupported}
240 {...(enabled || unsupported ? {} : { statusText: "Off" })}
239 value={enabled}241 value={enabled}
240 />242 />
241 </Pressable>243 </Pressable>
...@@ -270,6 +272,7 @@ function CloudEnvironmentRow(props: {...@@ -270,6 +272,7 @@ function CloudEnvironmentRow(props: {
270 }272 }
271 }}273 }}
272 onToggleError={props.onToggleError}274 onToggleError={props.onToggleError}
275 disabled={presentation.connectionState === "unsupported"}
273 statusText={presentation.statusText}276 statusText={presentation.statusText}
274 value={false}277 value={false}
275 />278 />
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+7-4
...@@ -19,7 +19,7 @@ import { serverEnvironment } from "../../state/server";...@@ -19,7 +19,7 @@ import { serverEnvironment } from "../../state/server";
19import { ConnectionStatusDot } from "./ConnectionStatusDot";19import { ConnectionStatusDot } from "./ConnectionStatusDot";
2020
21function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {21function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
22 if (!environment.isEnabled) {22 if (!environment.isEnabled && environment.connectionState !== "unsupported") {
23 return "Off";23 return "Off";
24 }24 }
25 return connectionStatusText({25 return connectionStatusText({
...@@ -46,10 +46,12 @@ export function ConnectionEnvironmentRow(props: {...@@ -46,10 +46,12 @@ export function ConnectionEnvironmentRow(props: {
46 const serverConfig = useAtomValue(46 const serverConfig = useAtomValue(
47 serverEnvironment.configValueAtom(props.environment.environmentId),47 serverEnvironment.configValueAtom(props.environment.environmentId),
48 );48 );
49 const enabled = props.environment.isEnabled;49 const unsupported = props.environment.connectionState === "unsupported";
50 const enabled = props.environment.isEnabled && !unsupported;
50 const statusLabel = connectionStatusLabel(props.environment);51 const statusLabel = connectionStatusLabel(props.environment);
51 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;52 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
52 const hasConnectionFailure = enabled && props.environment.connectionError !== null;53 const hasConnectionFailure =
54 (enabled || unsupported) && props.environment.connectionError !== null;
53 const isRetrying =55 const isRetrying =
54 enabled &&56 enabled &&
55 (props.environment.connectionState === "connecting" ||57 (props.environment.connectionState === "connecting" ||
...@@ -77,7 +79,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -77,7 +79,7 @@ export function ConnectionEnvironmentRow(props: {
77 onPress={props.onToggle}79 onPress={props.onToggle}
78 >80 >
79 <ConnectionStatusDot81 <ConnectionStatusDot
80 state={enabled ? props.environment.connectionState : "available"}82 state={enabled || unsupported ? props.environment.connectionState : "available"}
81 pulse={isRetrying}83 pulse={isRetrying}
82 size={8}84 size={8}
83 />85 />
...@@ -133,6 +135,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -133,6 +135,7 @@ export function ConnectionEnvironmentRow(props: {
133 </View>135 </View>
134136
135 <ThemedSwitch137 <ThemedSwitch
138 disabled={unsupported}
136 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}139 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
137 value={enabled}140 value={enabled}
138 />141 />
apps/mobile/src/state/use-remote-environment-registry.ts+10-1
...@@ -128,7 +128,16 @@ export function useRemoteConnections() {...@@ -128,7 +128,16 @@ export function useRemoteConnections() {
128 const error = Cause.squash(result.cause);128 const error = Cause.squash(result.cause);
129 const message =129 const message =
130 error instanceof Error ? error.message : "Failed to pair with the environment.";130 error instanceof Error ? error.message : "Failed to pair with the environment.";
131 setPendingConnectionError(message);131 if (
132 error !== null &&
133 typeof error === "object" &&
134 "reason" in error &&
135 error.reason === "unsupported"
136 ) {
137 Alert.alert("Client not supported", message);
138 } else {
139 setPendingConnectionError(message);
140 }
132 } else {141 } else {
133 appAtomRegistry.set(connectionPairingUrlAtom, "");142 appAtomRegistry.set(connectionPairingUrlAtom, "");
134 }143 }
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+79-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { Discovery } from "@t3tools/client-runtime/relay";1import 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 } from "@t3tools/contracts";3import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
6import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";6import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
...@@ -178,6 +178,84 @@ describe("cloud onboarding discovery", () => {...@@ -178,6 +178,84 @@ describe("cloud onboarding discovery", () => {
178 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);178 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
179 });179 });
180180
181 it("keeps incompatible discoveries unselected until the user enables a compatible server", async () => {
182 const base = linkedMachines.get(newMachineId)!;
183 const entry = (protocolVersion: number) => ({
184 ...base,
185 status: Option.some({
186 environmentId: newMachineId,
187 endpoint: base.environment.endpoint,
188 status: "online" as const,
189 checkedAt: "2026-09-15T00:00:00Z",
190 descriptor: {
191 environmentId: newMachineId,
192 label: base.environment.label,
193 platform: { os: "linux" as const, arch: "x64" as const },
194 serverVersion: "1.0.0",
195 orchestrationProtocolVersion: protocolVersion,
196 capabilities: { repositoryIdentity: true },
197 },
198 }),
199 });
200 discovery.listEnvironments.mockResolvedValue(
201 new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION + 1)]]),
202 );
203 const onSelectionChange = vi.fn();
204 const autoSelectedComputers = new Set<EnvironmentId>();
205 function Setup() {
206 const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId>>(
207 new Set([newMachineId]),
208 );
209 return (
210 <CloudEnvironmentConnectRows
211 primaryEnvironmentId={null}
212 savedEnvironments={[]}
213 showSavedEnvironments
214 selection={{
215 autoSelectedComputers,
216 selectedIds,
217 onChange: (id, checked) => {
218 onSelectionChange(id, checked);
219 setSelectedIds((current) => {
220 const next = new Set(current);
221 if (checked) next.add(id);
222 else next.delete(id);
223 return next;
224 });
225 },
226 }}
227 />
228 );
229 }
230 await act(async () => {
231 renderer = create(<Setup />);
232 });
233 expect(discovery.register).not.toHaveBeenCalled();
234 expect(onSelectionChange).toHaveBeenCalledWith(newMachineId, false);
235 expect(renderer!.root.findByType("input").props.checked).toBe(false);
236 expect(renderer!.root.findByType("input").props.disabled).toBe(true);
237 expect(renderer!.root.findAllByType("span").flatMap((span) => span.children)).toContain(
238 "Client not supported",
239 );
240 await act(async () => {
241 await renderer!.root.findByType("input").props.onChange({ target: { checked: true } });
242 });
243 expect(discovery.register).not.toHaveBeenCalled();
244 await act(async () =>
245 publish({
246 ...discovery.state!,
247 environments: new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION)]]),
248 }),
249 );
250 expect(discovery.register).not.toHaveBeenCalled();
251 expect(renderer!.root.findByType("input").props.checked).toBe(false);
252 expect(renderer!.root.findByType("input").props.disabled).toBe(false);
253 await act(async () => {
254 await renderer!.root.findByType("input").props.onChange({ target: { checked: true } });
255 });
256 expect(discovery.register).toHaveBeenCalledTimes(1);
257 });
258
181 it("connects and selects discovered computers by default without overwriting deselection", async () => {259 it("connects and selects discovered computers by default without overwriting deselection", async () => {
182 discovery.listEnvironments.mockResolvedValue(linkedMachines);260 discovery.listEnvironments.mockResolvedValue(linkedMachines);
183 const autoSelectedComputers = new Set<EnvironmentId>();261 const autoSelectedComputers = new Set<EnvironmentId>();
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+44-8
...@@ -3,13 +3,17 @@ import {...@@ -3,13 +3,17 @@ import {
3 type EnvironmentConnectionPresentation,3 type EnvironmentConnectionPresentation,
4 RelayConnectionRegistration,4 RelayConnectionRegistration,
5 RelayConnectionTarget,5 RelayConnectionTarget,
6 orchestrationProtocolCompatibilityError,
6} from "@t3tools/client-runtime/connection";7} from "@t3tools/client-runtime/connection";
7import {8import {
8 isAtomCommandInterrupted,9 isAtomCommandInterrupted,
9 squashAtomCommandFailure,10 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";11} from "@t3tools/client-runtime/state/runtime";
11import type { EnvironmentId } from "@t3tools/contracts";12import type { EnvironmentId } from "@t3tools/contracts";
12import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";13import type {
14 RelayClientEnvironmentRecord,
15 RelayEnvironmentStatusResponse,
16} from "@t3tools/contracts/relay";
13import * as Option from "effect/Option";17import * as Option from "effect/Option";
14import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";18import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";
1519
...@@ -29,6 +33,13 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec...@@ -29,6 +33,13 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec
2933
30const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;34const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
3135
36function discoveredCompatibilityError(
37 status: Option.Option<RelayEnvironmentStatusResponse> | undefined,
38) {
39 const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor;
40 return descriptor === undefined ? null : orchestrationProtocolCompatibilityError(descriptor);
41}
42
32export interface SavedCloudEnvironmentConnection {43export interface SavedCloudEnvironmentConnection {
33 readonly environmentId: EnvironmentId;44 readonly environmentId: EnvironmentId;
34 readonly connection: EnvironmentConnectionPresentation;45 readonly connection: EnvironmentConnectionPresentation;
...@@ -119,6 +130,12 @@ export function CloudEnvironmentConnectRows({...@@ -119,6 +130,12 @@ export function CloudEnvironmentConnectRows({
119 }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);130 }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);
120131
121 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {132 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
133 if (
134 discoveredCompatibilityError(
135 environmentsState.environments.get(environment.environmentId)?.status,
136 ) !== null
137 )
138 return false;
122 setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));139 setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));
123 const result = await connectRelayEnvironment(environment);140 const result = await connectRelayEnvironment(environment);
124 setConnectingEnvironmentIds((current) => {141 setConnectingEnvironmentIds((current) => {
...@@ -166,8 +183,17 @@ export function CloudEnvironmentConnectRows({...@@ -166,8 +183,17 @@ export function CloudEnvironmentConnectRows({
166 const selectNewComputers = useEffectEvent(() => {183 const selectNewComputers = useEffectEvent(() => {
167 const seen = selection?.autoSelectedComputers;184 const seen = selection?.autoSelectedComputers;
168 if (!selection || !seen) return;185 if (!selection || !seen) return;
169 for (const { environment } of visibleEnvironments) {186 for (const { environment, status, availability } of visibleEnvironments) {
170 const id = environment.environmentId;187 const id = environment.environmentId;
188 if (availability === "checking") continue;
189 if (
190 discoveredCompatibilityError(status) !== null ||
191 savedById.get(id)?.connection.phase === "unsupported"
192 ) {
193 seen.add(id);
194 if (selection.selectedIds.has(id)) selection.onChange(id, false);
195 continue;
196 }
171 if (seen.has(id)) continue;197 if (seen.has(id)) continue;
172 seen.add(id);198 seen.add(id);
173 selection.onChange(id, true);199 selection.onChange(id, true);
...@@ -265,11 +291,20 @@ export function CloudEnvironmentConnectRows({...@@ -265,11 +291,20 @@ export function CloudEnvironmentConnectRows({
265 return empty;291 return empty;
266 }292 }
267293
268 return visibleEnvironments.map(({ environment, availability, error }) => {294 return visibleEnvironments.map(({ environment, availability, error, status }) => {
269 const savedEnvironment = savedById.get(environment.environmentId);295 const savedEnvironment = savedById.get(environment.environmentId);
270 const savedConnection = savedEnvironment296 const compatibilityError = discoveredCompatibilityError(status);
271 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)297 const unsupported =
272 : null;298 compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported";
299 const savedConnection = unsupported
300 ? presentSavedCloudEnvironmentConnection({
301 phase: "unsupported",
302 error: compatibilityError?.message ?? savedEnvironment?.connection.error ?? null,
303 traceId: null,
304 })
305 : savedEnvironment
306 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
307 : null;
273 const dotClassName = savedConnection308 const dotClassName = savedConnection
274 ? savedConnection.tone === "connected"309 ? savedConnection.tone === "connected"
275 ? "bg-success"310 ? "bg-success"
...@@ -302,9 +337,10 @@ export function CloudEnvironmentConnectRows({...@@ -302,9 +337,10 @@ export function CloudEnvironmentConnectRows({
302 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 has-disabled:cursor-default"337 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 has-disabled:cursor-default"
303 >338 >
304 <Checkbox339 <Checkbox
305 checked={selection.selectedIds.has(environment.environmentId)}340 checked={!unsupported && selection.selectedIds.has(environment.environmentId)}
306 disabled={connectingEnvironmentIds.has(environment.environmentId)}341 disabled={unsupported || connectingEnvironmentIds.has(environment.environmentId)}
307 onCheckedChange={async (checked) => {342 onCheckedChange={async (checked) => {
343 if (unsupported) return;
308 selection.onChange(environment.environmentId, checked);344 selection.onChange(environment.environmentId, checked);
309 if (checked && !savedEnvironment) {345 if (checked && !savedEnvironment) {
310 const connected = await connectEnvironment(environment);346 const connected = await connectEnvironment(environment);
apps/web/src/components/settings/ConnectionsSettings.tsx+13-6
...@@ -1444,7 +1444,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): {...@@ -1444,7 +1444,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
1444 readonly text: string;1444 readonly text: string;
1445 readonly tone: "muted" | "error";1445 readonly tone: "muted" | "error";
1446} {1446} {
1447 if (!environment.entry.enabled) return { text: "Off", tone: "muted" };1447 if (!environment.entry.enabled && environment.connection.phase !== "unsupported")
1448 return { text: "Off", tone: "muted" };
1448 const { connection } = environment;1449 const { connection } = environment;
1449 switch (connection.phase) {1450 switch (connection.phase) {
1450 case "connected":1451 case "connected":
...@@ -1482,7 +1483,8 @@ function SavedBackendListRow({...@@ -1482,7 +1483,8 @@ function SavedBackendListRow({
1482 onRemove,1483 onRemove,
1483}: SavedBackendListRowProps) {1484}: SavedBackendListRowProps) {
1484 const environmentId = environment.environmentId;1485 const environmentId = environment.environmentId;
1485 const enabled = environment.entry.enabled;1486 const unsupported = environment.connection.phase === "unsupported";
1487 const enabled = environment.entry.enabled && !unsupported;
1486 const isConnected = environment.connection.phase === "connected";1488 const isConnected = environment.connection.phase === "connected";
1487 const isRemoving = removingEnvironmentId === environmentId;1489 const isRemoving = removingEnvironmentId === environmentId;
1488 const errorTraceId = environment.connection.traceId;1490 const errorTraceId = environment.connection.traceId;
...@@ -1545,7 +1547,10 @@ function SavedBackendListRow({...@@ -1545,7 +1547,10 @@ function SavedBackendListRow({
1545 <span1547 <span
1546 className={cn(1548 className={cn(
1547 "block truncate",1549 "block truncate",
1548 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",1550 (enabled || unsupported) &&
1551 status.tone === "error" &&
1552 !resumingServerUpdate &&
1553 "text-destructive",
1549 )}1554 )}
1550 />1555 />
1551 }1556 }
...@@ -1553,7 +1558,7 @@ function SavedBackendListRow({...@@ -1553,7 +1558,7 @@ function SavedBackendListRow({
1553 {subtitleText}1558 {subtitleText}
1554 </TooltipTrigger>1559 </TooltipTrigger>
1555 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">1560 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1556 {enabled ? connectionStatusText(environment.connection) : "Switched off"}1561 {enabled || unsupported ? connectionStatusText(environment.connection) : "Switched off"}
1557 {versionMismatch1562 {versionMismatch
1558 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`1563 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
1559 : ""}1564 : ""}
...@@ -1586,13 +1591,15 @@ function SavedBackendListRow({...@@ -1586,13 +1591,15 @@ function SavedBackendListRow({
1586 <Switch1591 <Switch
1587 size="sm"1592 size="sm"
1588 checked={enabled}1593 checked={enabled}
1589 disabled={isRemoving}1594 disabled={isRemoving || unsupported}
1590 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}1595 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1591 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}1596 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1592 />1597 />
1593 }1598 }
1594 />1599 />
1595 <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup>1600 <TooltipPopup side="top">
1601 {unsupported ? "Client not supported" : enabled ? "Switch off" : "Switch on"}
1602 </TooltipPopup>
1596 </Tooltip>1603 </Tooltip>
1597 <Menu>1604 <Menu>
1598 <MenuTrigger1605 <MenuTrigger
packages/client-runtime/src/connection/catalog.ts+2
...@@ -41,6 +41,8 @@ export interface ConnectionCatalogEntry {...@@ -41,6 +41,8 @@ export interface ConnectionCatalogEntry {
41 readonly profile: Option.Option<ConnectionProfile>;41 readonly profile: Option.Option<ConnectionProfile>;
42 /** False when the user switched the environment off: saved, but never connects. */42 /** False when the user switched the environment off: saved, but never connects. */
43 readonly enabled: boolean;43 readonly enabled: boolean;
44 /** Discovery rejection stays visible while the saved connection is switched off. */
45 readonly unsupportedReason?: string;
44}46}
4547
46export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(48export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
packages/client-runtime/src/connection/index.ts+2
...@@ -21,3 +21,5 @@ export {...@@ -21,3 +21,5 @@ export {
21} from "./registry.ts";21} from "./registry.ts";
22export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts";22export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts";
23export * as Wakeups from "./wakeups.ts";23export * as Wakeups from "./wakeups.ts";
24
25export { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
packages/client-runtime/src/connection/layer.ts+48
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
3import * as Stream from "effect/Stream";4import * as Stream from "effect/Stream";
5import * as Option from "effect/Option";
6import * as SubscriptionRef from "effect/SubscriptionRef";
7import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
48
5import * as ConnectionResolver from "./resolver.ts";9import * as ConnectionResolver from "./resolver.ts";
6import * as ConnectionDriver from "./driver.ts";10import * as ConnectionDriver from "./driver.ts";
...@@ -11,6 +15,49 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";...@@ -11,6 +15,49 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";15import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
12import * as RpcSession from "../rpc/session.ts";16import * as RpcSession from "../rpc/session.ts";
1317
18export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")(
19 function* () {
20 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
21 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
22 const seenChecks = new Map<string, RelayEnvironmentStatusResponse>();
23 yield* Stream.merge(
24 SubscriptionRef.changes(discovery.state),
25 SubscriptionRef.changes(registry.entries),
26 ).pipe(
27 Stream.runForEach(() =>
28 Effect.gen(function* () {
29 const current = yield* SubscriptionRef.get(discovery.state);
30 if (!current.refreshing) {
31 for (const environmentId of seenChecks.keys()) {
32 if (!current.environments.has(environmentId)) seenChecks.delete(environmentId);
33 }
34 }
35 for (const entry of current.environments.values()) {
36 const status = Option.getOrNull(entry.status);
37 const descriptor = status?.descriptor;
38 if (status === null || descriptor === undefined) continue;
39 const environmentId = entry.environment.environmentId;
40 const previous = seenChecks.get(environmentId);
41 const fresh =
42 previous?.checkedAt !== status.checkedAt ||
43 (previous.descriptor?.orchestrationProtocolVersion ?? 1) !==
44 (descriptor.orchestrationProtocolVersion ?? 1) ||
45 previous.descriptor?.serverVersion !== descriptor.serverVersion;
46 const error = orchestrationProtocolCompatibilityError(descriptor);
47 // A replayed health result must not clear a newer socket rejection.
48 if (error !== null || fresh) yield* registry.setCompatibility(environmentId, error);
49 seenChecks.set(environmentId, status);
50 }
51 }).pipe(
52 Effect.catch((error) =>
53 Effect.logWarning("Could not apply discovered environment compatibility.", { error }),
54 ),
55 ),
56 ),
57 );
58 },
59);
60
14export function layerWithOptions(options: RpcSession.RpcSessionOptions) {61export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
15 const driverLayer = ConnectionDriver.layer.pipe(62 const driverLayer = ConnectionDriver.layer.pipe(
16 Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))),63 Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))),
...@@ -26,6 +73,7 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {...@@ -26,6 +73,7 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
26 Effect.gen(function* () {73 Effect.gen(function* () {
27 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;74 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
28 const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;75 const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;
76 yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped);
29 yield* registry.start;77 yield* registry.start;
30 yield* platformSource.registrations.pipe(78 yield* platformSource.registrations.pipe(
31 Stream.runForEach(registry.reconcilePlatform),79 Stream.runForEach(registry.reconcilePlatform),
packages/client-runtime/src/connection/onboarding.test.ts+29-2
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";1import {
2 AuthStandardClientScopes,
3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_VERSION,
5} from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -28,7 +32,7 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed(...@@ -28,7 +32,7 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed(
2832
29function pairingHttpLayer(33function pairingHttpLayer(
30 calls: Array<{ readonly url: string; readonly init: RequestInit }>,34 calls: Array<{ readonly url: string; readonly init: RequestInit }>,
31 options?: { readonly failDescriptor?: boolean },35 options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number },
32) {36) {
33 const fetchFn = ((input, init = {}) => {37 const fetchFn = ((input, init = {}) => {
34 const url = String(input);38 const url = String(input);
...@@ -49,6 +53,7 @@ function pairingHttpLayer(...@@ -49,6 +53,7 @@ function pairingHttpLayer(
49 arch: "x64",53 arch: "x64",
50 },54 },
51 serverVersion: "0.0.0-test",55 serverVersion: "0.0.0-test",
56 orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION,
52 capabilities: {57 capabilities: {
53 repositoryIdentity: true,58 repositoryIdentity: true,
54 },59 },
...@@ -118,6 +123,28 @@ describe("connection onboarding", () => {...@@ -118,6 +123,28 @@ describe("connection onboarding", () => {
118 }),123 }),
119 );124 );
120125
126 it.effect("rejects an incompatible server without consuming the pairing credential", () =>
127 Effect.gen(function* () {
128 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
129 const error = yield* preparePairingRegistration({
130 host: "remote.example.test",
131 pairingCode: "pairing-token",
132 }).pipe(
133 Effect.provide(
134 Layer.mergeAll(
135 CLIENT_PRESENTATION_LAYER,
136 pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1 }),
137 ),
138 ),
139 Effect.flip,
140 );
141 expect(error).toMatchObject({ reason: "unsupported" });
142 expect(calls.map((call) => call.url)).toEqual([
143 "https://remote.example.test/.well-known/t3/environment",
144 ]);
145 }),
146 );
147
121 it.effect("does not consume a pairing credential when descriptor discovery fails", () =>148 it.effect("does not consume a pairing credential when descriptor discovery fails", () =>
122 Effect.gen(function* () {149 Effect.gen(function* () {
123 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];150 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
packages/client-runtime/src/connection/onboarding.ts+3
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "./model.ts";31} from "./model.ts";
32import * as Persistence from "../platform/persistence.ts";32import * as Persistence from "../platform/persistence.ts";
33import * as EnvironmentRegistry from "./registry.ts";33import * as EnvironmentRegistry from "./registry.ts";
34import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
3435
35export interface PairingConnectionInput {36export interface PairingConnectionInput {
36 readonly pairingUrl?: string;37 readonly pairingUrl?: string;
...@@ -91,6 +92,8 @@ export const preparePairingRegistration = Effect.fn(...@@ -91,6 +92,8 @@ export const preparePairingRegistration = Effect.fn(
91 const descriptor = yield* fetchRemoteEnvironmentDescriptor({92 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
92 httpBaseUrl: target.httpBaseUrl,93 httpBaseUrl: target.httpBaseUrl,
93 }).pipe(Effect.mapError(mapRemoteEnvironmentError));94 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
95 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
96 if (compatibilityError !== null) return yield* compatibilityError;
94 const access = yield* bootstrapRemoteBearerSession({97 const access = yield* bootstrapRemoteBearerSession({
95 httpBaseUrl: target.httpBaseUrl,98 httpBaseUrl: target.httpBaseUrl,
96 credential: target.credential,99 credential: target.credential,
packages/client-runtime/src/connection/registry.test.ts+263
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import {1import {
2 type DesktopSshEnvironmentTarget,2 type DesktopSshEnvironmentTarget,
3 EnvironmentId,3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_VERSION,
5 type ExecutionEnvironmentDescriptor,
4 type OrchestrationShellSnapshot,6 type OrchestrationShellSnapshot,
5} from "@t3tools/contracts";7} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
...@@ -36,6 +38,7 @@ import * as ConnectionCredentialStore from "./credentialStore.ts";...@@ -36,6 +38,7 @@ import * as ConnectionCredentialStore from "./credentialStore.ts";
36import * as ConnectionDriver from "./driver.ts";38import * as ConnectionDriver from "./driver.ts";
37import {39import {
38 ConnectionTransientError,40 ConnectionTransientError,
41 ConnectionBlockedError,
39 BearerConnectionTarget,42 BearerConnectionTarget,
40 PrimaryConnectionTarget,43 PrimaryConnectionTarget,
41 RelayConnectionTarget,44 RelayConnectionTarget,
...@@ -54,6 +57,9 @@ import {...@@ -54,6 +57,9 @@ import {
54import * as RpcSession from "../rpc/session.ts";57import * as RpcSession from "../rpc/session.ts";
55import * as EnvironmentSupervisor from "./supervisor.ts";58import * as EnvironmentSupervisor from "./supervisor.ts";
56import * as ConnectionWakeups from "./wakeups.ts";59import * as ConnectionWakeups from "./wakeups.ts";
60import { watchDiscoveredCompatibility } from "./layer.ts";
61import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
62import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
57import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";63import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
5864
59const TARGET = new PrimaryConnectionTarget({65const TARGET = new PrimaryConnectionTarget({
...@@ -137,6 +143,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -137,6 +143,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
137 initialProfiles: ReadonlyArray<ConnectionProfile> = [],143 initialProfiles: ReadonlyArray<ConnectionProfile> = [],
138 initialCredentials: ReadonlyArray<readonly [string, ConnectionCredential]> = [],144 initialCredentials: ReadonlyArray<readonly [string, ConnectionCredential]> = [],
139 options?: {145 options?: {
146 readonly prepareError?: ConnectionBlockedError;
140 readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>;147 readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>;
141 readonly beforeRegistrationRegister?: (148 readonly beforeRegistrationRegister?: (
142 registration: ConnectionRegistration,149 registration: ConnectionRegistration,
...@@ -368,6 +375,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -368,6 +375,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
368 target,375 target,
369 };376 };
370 yield* reportProgress({ stage: "preparing" });377 yield* reportProgress({ stage: "preparing" });
378 if (options?.prepareError) return yield* options.prepareError;
371 yield* reportProgress({ stage: "opening", prepared });379 yield* reportProgress({ stage: "opening", prepared });
372 yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void;380 yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void;
373 const closed = yield* Deferred.make<never, ConnectionTransientError>();381 const closed = yield* Deferred.make<never, ConnectionTransientError>();
...@@ -673,6 +681,212 @@ describe("EnvironmentRegistry", () => {...@@ -673,6 +681,212 @@ describe("EnvironmentRegistry", () => {
673 }),681 }),
674 );682 );
675683
684 it.effect("only a fresh health check for the rejected environment unlocks it", () =>
685 Effect.gen(function* () {
686 const harness = yield* makeHarness([RELAY_TARGET], [], [], {
687 initialDisabled: [RELAY_TARGET.environmentId],
688 });
689 const descriptor = (environmentId: EnvironmentId): ExecutionEnvironmentDescriptor => ({
690 environmentId,
691 label: "Server",
692 platform: { os: "linux", arch: "x64" },
693 serverVersion: "1.0.0",
694 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
695 capabilities: { repositoryIdentity: true },
696 });
697 const discovered = (
698 value: ExecutionEnvironmentDescriptor,
699 checkedAt = "2026-09-15T00:00:00Z",
700 ) => {
701 const environment = {
702 environmentId: value.environmentId,
703 label: value.label,
704 endpoint: {
705 httpBaseUrl: "https://relay.example.test",
706 wsBaseUrl: "wss://relay.example.test",
707 providerKind: "manual" as const,
708 },
709 linkedAt: "2026-09-15T00:00:00Z",
710 };
711 const status: RelayEnvironmentStatusResponse = {
712 environmentId: value.environmentId,
713 endpoint: environment.endpoint,
714 status: "online",
715 checkedAt,
716 descriptor: value,
717 };
718 return {
719 environment,
720 availability: "online" as const,
721 status: Option.some(status),
722 error: Option.none(),
723 };
724 };
725 const original = discovered(descriptor(RELAY_TARGET.environmentId));
726 const discoveryState =
727 yield* SubscriptionRef.make<RelayEnvironmentDiscovery.RelayEnvironmentDiscoveryState>({
728 ...RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
729 environments: new Map([[RELAY_TARGET.environmentId, original]]),
730 });
731 const initial = yield* Deferred.make<void>();
732 const unrelated = yield* Deferred.make<void>();
733 const replayed = yield* Deferred.make<void>();
734 const refreshed = yield* Deferred.make<void>();
735 let firstEnvironmentCalls = 0;
736 let secondEnvironmentCalls = 0;
737 yield* Effect.gen(function* () {
738 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
739 yield* watchDiscoveredCompatibility().pipe(
740 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, {
741 ...registry,
742 setCompatibility: (environmentId, error) =>
743 registry.setCompatibility(environmentId, error).pipe(
744 Effect.andThen(
745 Effect.gen(function* () {
746 if (environmentId === RELAY_TARGET.environmentId) {
747 firstEnvironmentCalls += 1;
748 yield* Deferred.succeed(
749 firstEnvironmentCalls === 1 ? initial : refreshed,
750 undefined,
751 );
752 } else {
753 secondEnvironmentCalls += 1;
754 yield* Deferred.succeed(
755 secondEnvironmentCalls === 1 ? unrelated : replayed,
756 undefined,
757 );
758 }
759 }),
760 ),
761 ),
762 }),
763 Effect.provideService(
764 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery,
765 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({
766 state: discoveryState,
767 refresh: Effect.void,
768 }),
769 ),
770 Effect.forkScoped,
771 );
772 yield* Deferred.await(initial);
773 const error = new ConnectionBlockedError({
774 reason: "unsupported",
775 detail: "Socket discovered a newer protocol.",
776 });
777 yield* registry.setCompatibility(RELAY_TARGET.environmentId, error);
778 yield* SubscriptionRef.update(discoveryState, (state) => ({
779 ...state,
780 environments: new Map(state.environments).set(
781 SECOND_TARGET.environmentId,
782 discovered(descriptor(SECOND_TARGET.environmentId)),
783 ),
784 }));
785 yield* Deferred.await(unrelated);
786 expect(
787 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)
788 ?.unsupportedReason,
789 ).toBe(error.message);
790 yield* SubscriptionRef.update(discoveryState, (state) => ({
791 ...state,
792 refreshing: true,
793 environments: new Map(),
794 }));
795 yield* SubscriptionRef.update(discoveryState, (state) => ({
796 ...state,
797 refreshing: false,
798 environments: new Map([
799 [RELAY_TARGET.environmentId, discovered(descriptor(RELAY_TARGET.environmentId))],
800 [
801 SECOND_TARGET.environmentId,
802 discovered(descriptor(SECOND_TARGET.environmentId), "2026-09-15T00:01:00Z"),
803 ],
804 ]),
805 }));
806 yield* Deferred.await(replayed);
807 expect(
808 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)
809 ?.unsupportedReason,
810 ).toBe(error.message);
811 yield* SubscriptionRef.update(discoveryState, (state) => ({
812 ...state,
813 environments: new Map(state.environments).set(
814 RELAY_TARGET.environmentId,
815 discovered(descriptor(RELAY_TARGET.environmentId), "2026-09-15T00:02:00Z"),
816 ),
817 }));
818 yield* Deferred.await(refreshed);
819 expect(
820 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId),
821 ).toMatchObject({ enabled: false });
822 expect(
823 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)
824 ?.unsupportedReason,
825 ).toBeUndefined();
826 }).pipe(Effect.provide(harness.layer), Effect.scoped);
827 }),
828 );
829
830 it.effect("discovery keeps unsupported environments off until compatibility changes", () =>
831 Effect.gen(function* () {
832 const harness = yield* makeHarness([RELAY_TARGET], [], [], {
833 initialDisabled: [RELAY_TARGET.environmentId],
834 });
835 yield* Effect.gen(function* () {
836 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
837 yield* registry.start;
838 const error = new ConnectionBlockedError({
839 reason: "unsupported",
840 detail: "Use a compatible client.",
841 });
842 yield* registry.setCompatibility(RELAY_TARGET.environmentId, error);
843 const entry = (yield* SubscriptionRef.get(registry.entries)).get(
844 RELAY_TARGET.environmentId,
845 );
846 expect(entry).toMatchObject({ enabled: false, unsupportedReason: error.message });
847 expect(
848 yield* Effect.flip(registry.setEnabled(RELAY_TARGET.environmentId, true)),
849 ).toMatchObject({ reason: "unsupported" });
850 expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
851 yield* registry.setCompatibility(RELAY_TARGET.environmentId, null);
852 expect(
853 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)?.enabled,
854 ).toBe(false);
855 yield* registry.setEnabled(RELAY_TARGET.environmentId, true);
856 yield* awaitConnectionState(
857 registry,
858 RELAY_TARGET.environmentId,
859 (state) => state.phase === "connected",
860 );
861 }).pipe(Effect.provide(harness.layer));
862 }),
863 );
864
865 it.effect("a socket preflight rejection persists the connection as switched off", () =>
866 Effect.gen(function* () {
867 const error = new ConnectionBlockedError({
868 reason: "unsupported",
869 detail: "Use a compatible client.",
870 });
871 const harness = yield* makeHarness([RELAY_TARGET], [], [], { prepareError: error });
872 yield* Effect.gen(function* () {
873 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
874 yield* registry.start;
875 yield* SubscriptionRef.changes(registry.entries).pipe(
876 Stream.filter((entries) => entries.get(RELAY_TARGET.environmentId)?.enabled === false),
877 Stream.take(1),
878 Stream.runDrain,
879 );
880 expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true);
881 expect(
882 (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)
883 ?.unsupportedReason,
884 ).toBe(error.message);
885 expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
886 }).pipe(Effect.provide(harness.layer));
887 }),
888 );
889
676 it.effect("switching an environment off disconnects it and persists the flag", () =>890 it.effect("switching an environment off disconnects it and persists the flag", () =>
677 Effect.gen(function* () {891 Effect.gen(function* () {
678 const harness = yield* makeHarness([RELAY_TARGET]);892 const harness = yield* makeHarness([RELAY_TARGET]);
...@@ -1095,6 +1309,55 @@ describe("EnvironmentRegistry", () => {...@@ -1095,6 +1309,55 @@ describe("EnvironmentRegistry", () => {
1095 }),1309 }),
1096 );1310 );
10971311
1312 it.effect("platform refreshes preserve unsupported state for the same endpoint", () =>
1313 Effect.gen(function* () {
1314 const harness = yield* makeHarness([]);
1315 yield* Effect.gen(function* () {
1316 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1317 const registration = new PrimaryConnectionRegistration({ target: TARGET });
1318 yield* registry.registerPlatform(registration);
1319 yield* awaitConnectionState(
1320 registry,
1321 TARGET.environmentId,
1322 (state) => state.phase === "connected",
1323 );
1324 const error = new ConnectionBlockedError({
1325 reason: "unsupported",
1326 detail: "Use a compatible client.",
1327 });
1328 yield* registry.setCompatibility(TARGET.environmentId, error);
1329 yield* awaitConnectionState(
1330 registry,
1331 TARGET.environmentId,
1332 (state) => state.phase === "available",
1333 );
1334 yield* registry.registerPlatform(registration);
1335 yield* registry.reconcilePlatform([registration]);
1336 expect(
1337 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId),
1338 ).toMatchObject({ enabled: false, unsupportedReason: error.message });
1339 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);
1340 yield* registry.registerPlatform(
1341 new PrimaryConnectionRegistration({
1342 target: new PrimaryConnectionTarget({
1343 ...TARGET,
1344 httpBaseUrl: "https://changed.example.test",
1345 }),
1346 }),
1347 );
1348 yield* awaitConnectionState(
1349 registry,
1350 TARGET.environmentId,
1351 (state) => state.phase === "connected",
1352 );
1353 expect(
1354 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)
1355 ?.unsupportedReason,
1356 ).toBeUndefined();
1357 }).pipe(Effect.provide(harness.layer));
1358 }),
1359 );
1360
1098 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>1361 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>
1099 Effect.gen(function* () {1362 Effect.gen(function* () {
1100 const harness = yield* makeHarness([]);1363 const harness = yield* makeHarness([]);
packages/client-runtime/src/connection/registry.ts+81-4
...@@ -30,6 +30,7 @@ import type {...@@ -30,6 +30,7 @@ import type {
30 NetworkStatus,30 NetworkStatus,
31 SupervisorConnectionState,31 SupervisorConnectionState,
32} from "./model.ts";32} from "./model.ts";
33import { ConnectionBlockedError } from "./model.ts";
33import * as Persistence from "../platform/persistence.ts";34import * as Persistence from "../platform/persistence.ts";
34import * as EnvironmentSupervisor from "./supervisor.ts";35import * as EnvironmentSupervisor from "./supervisor.ts";
35import * as ConnectionDriver from "./driver.ts";36import * as ConnectionDriver from "./driver.ts";
...@@ -104,8 +105,14 @@ export class EnvironmentRegistry extends Context.Service<...@@ -104,8 +105,14 @@ export class EnvironmentRegistry extends Context.Service<
104 enabled: boolean,105 enabled: boolean,
105 ) => Effect.Effect<106 ) => Effect.Effect<
106 void,107 void,
107 EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError108 | EnvironmentNotRegisteredError
109 | Persistence.ConnectionPersistenceError
110 | ConnectionBlockedError
108 >;111 >;
112 readonly setCompatibility: (
113 environmentId: EnvironmentId,
114 error: ConnectionBlockedError | null,
115 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
109 readonly state: (116 readonly state: (
110 environmentId: EnvironmentId,117 environmentId: EnvironmentId,
111 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;118 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
...@@ -291,6 +298,21 @@ export const make = Effect.gen(function* () {...@@ -291,6 +298,21 @@ export const make = Effect.gen(function* () {
291 next.set(environmentId, { entry, supervisor, scope });298 next.set(environmentId, { entry, supervisor, scope });
292 return next;299 return next;
293 });300 });
301 yield* SubscriptionRef.changes(supervisor.state).pipe(
302 Stream.runForEach((state) =>
303 state.phase === "blocked" && state.lastFailure?.reason === "unsupported"
304 ? setCompatibility(environmentId, state.lastFailure).pipe(
305 Effect.catch((error) =>
306 Effect.logWarning("Could not disable an unsupported environment.", {
307 environmentId,
308 error,
309 }),
310 ),
311 )
312 : Effect.void,
313 ),
314 Effect.forkIn(scope),
315 );
294 return supervisor;316 return supervisor;
295 }),317 }),
296 ),318 ),
...@@ -426,7 +448,16 @@ export const make = Effect.gen(function* () {...@@ -426,7 +448,16 @@ export const make = Effect.gen(function* () {
426 // Editing a saved environment must preserve its disabled state.448 // Editing a saved environment must preserve its disabled state.
427 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);449 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
428 const entry: ConnectionCatalogEntry =450 const entry: ConnectionCatalogEntry =
429 previous === undefined ? registered : { ...registered, enabled: previous.enabled };451 previous === undefined
452 ? registered
453 : {
454 ...registered,
455 enabled: previous.enabled,
456 ...(previous.unsupportedReason !== undefined &&
457 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
458 ? { unsupportedReason: previous.unsupportedReason }
459 : {}),
460 };
430 if (461 if (
431 previous !== undefined &&462 previous !== undefined &&
432 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)463 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)
...@@ -454,12 +485,17 @@ export const make = Effect.gen(function* () {...@@ -454,12 +485,17 @@ export const make = Effect.gen(function* () {
454485
455 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(486 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(
456 function* (registration: PlatformConnectionRegistration) {487 function* (registration: PlatformConnectionRegistration) {
457 const entry = connectionRegistrationCatalogEntry(registration);488 const registered = connectionRegistrationCatalogEntry(registration);
458 const target = entry.target;489 const target = registered.target;
459 yield* withLeaseLock(490 yield* withLeaseLock(
460 target.environmentId,491 target.environmentId,
461 Effect.gen(function* () {492 Effect.gen(function* () {
462 const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId);493 const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId);
494 const entry: ConnectionCatalogEntry =
495 previous?.unsupportedReason !== undefined &&
496 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
497 ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason }
498 : registered;
463 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(499 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
464 target.environmentId,500 target.environmentId,
465 );501 );
...@@ -714,6 +750,12 @@ export const make = Effect.gen(function* () {...@@ -714,6 +750,12 @@ export const make = Effect.gen(function* () {
714 environmentId,750 environmentId,
715 Effect.gen(function* () {751 Effect.gen(function* () {
716 const entry = yield* getEntry(environmentId);752 const entry = yield* getEntry(environmentId);
753 if (enabled && entry.unsupportedReason !== undefined) {
754 return yield* new ConnectionBlockedError({
755 reason: "unsupported",
756 detail: entry.unsupportedReason,
757 });
758 }
717 if (entry.enabled === enabled) {759 if (entry.enabled === enabled) {
718 return;760 return;
719 }761 }
...@@ -794,6 +836,40 @@ export const make = Effect.gen(function* () {...@@ -794,6 +836,40 @@ export const make = Effect.gen(function* () {
794 Effect.forkScoped,836 Effect.forkScoped,
795 );837 );
796838
839 const setCompatibility = Effect.fn("EnvironmentRegistry.setCompatibility")(function* (
840 environmentId: EnvironmentId,
841 error: ConnectionBlockedError | null,
842 ) {
843 yield* withLeaseLock(
844 environmentId,
845 Effect.gen(function* () {
846 const entry = (yield* SubscriptionRef.get(entries)).get(environmentId);
847 if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined))
848 return;
849 const { unsupportedReason: _previousReason, ...rest } = entry;
850 const next: ConnectionCatalogEntry =
851 error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message };
852 if (
853 error !== null &&
854 entry.enabled &&
855 !(yield* Ref.get(platformEnvironmentIds)).has(environmentId)
856 ) {
857 yield* registrations.setEnabled(environmentId, false);
858 }
859 const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId);
860 if (lease !== undefined) {
861 yield* SubscriptionRef.update(serviceScopes, (current) =>
862 new Map(current).set(environmentId, { ...lease, entry: next }),
863 );
864 if (error !== null) yield* lease.supervisor.disconnect;
865 }
866 yield* SubscriptionRef.update(entries, (current) =>
867 new Map(current).set(environmentId, next),
868 );
869 }),
870 );
871 });
872
797 return EnvironmentRegistry.of({873 return EnvironmentRegistry.of({
798 entries,874 entries,
799 networkStatus,875 networkStatus,
...@@ -805,6 +881,7 @@ export const make = Effect.gen(function* () {...@@ -805,6 +881,7 @@ export const make = Effect.gen(function* () {
805 removeRelayEnvironments,881 removeRelayEnvironments,
806 retryNow,882 retryNow,
807 setEnabled,883 setEnabled,
884 setCompatibility,
808 state,885 state,
809 stateChanges,886 stateChanges,
810 run,887 run,
packages/client-runtime/src/state/presentation.ts+4-1
...@@ -41,7 +41,10 @@ export function createEnvironmentPresentationAtoms<E>(input: {...@@ -41,7 +41,10 @@ export function createEnvironmentPresentationAtoms<E>(input: {
41 );41 );
42 return {42 return {
43 entry,43 entry,
44 connection: presentEnvironmentConnection(state),44 connection:
45 entry.unsupportedReason === undefined
46 ? presentEnvironmentConnection(state)
47 : { phase: "unsupported", error: entry.unsupportedReason, traceId: null },
45 serverConfig: get(input.serverConfigValueAtom(environmentId)),48 serverConfig: get(input.serverConfigValueAtom(environmentId)),
46 } satisfies EnvironmentPresentation;49 } satisfies EnvironmentPresentation;
47 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),50 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),
packages/client-runtime/src/state/threads-atoms.test.ts+1
...@@ -182,6 +182,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?...@@ -182,6 +182,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
183 retryNow: () => Effect.void,183 retryNow: () => Effect.void,
184 setEnabled: () => Effect.die("Unexpected environment toggle"),184 setEnabled: () => Effect.die("Unexpected environment toggle"),
185 setCompatibility: () => Effect.die("Unexpected compatibility update"),
185 state: () => SubscriptionRef.get(supervisor.state),186 state: () => SubscriptionRef.get(supervisor.state),
186 stateChanges: () => SubscriptionRef.changes(supervisor.state),187 stateChanges: () => SubscriptionRef.changes(supervisor.state),
187 run: (_environmentId, effect) =>188 run: (_environmentId, effect) =>