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 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
22import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
33import { describe, expect, it } from "vite-plus/test";
44
......@@ -24,6 +24,32 @@ function relayStatus(
2424}
2525
2626describe("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
2753 it("presents an online unsaved environment as available, not connected", () => {
2854 expect(
2955 availableCloudEnvironmentPresentation({
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts+16-1
......@@ -1,5 +1,8 @@
11import 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
47export interface AvailableCloudEnvironmentPresentation {
58 readonly connectionError: string | null;
......@@ -14,6 +17,18 @@ export function availableCloudEnvironmentPresentation(input: {
1417 readonly statusError: string | null;
1518 readonly statusErrorTraceId: string | null;
1619}): 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 }
1732 if (input.status?.status === "online") {
1833 return {
1934 connectionError: null,
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+7-4
......@@ -219,7 +219,8 @@ function ConnectedCloudEnvironmentRow(props: {
219219 const serverConfig = useAtomValue(
220220 serverEnvironment.configValueAtom(props.environment.environmentId),
221221 );
222 const enabled = props.environment.isEnabled;
222 const unsupported = props.environment.connectionState === "unsupported";
223 const enabled = props.environment.isEnabled && !unsupported;
223224 return (
224225 <Pressable
225226 accessibilityHint="Long press to remove from this device"
......@@ -227,15 +228,16 @@ function ConnectedCloudEnvironmentRow(props: {
227228 >
228229 <CloudEnvironmentRowShell
229230 borderTop={props.borderTop}
230 connectionError={enabled ? props.environment.connectionError : null}
231 connectionError={enabled || unsupported ? props.environment.connectionError : null}
231232 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
232 connectionState={enabled ? props.environment.connectionState : "available"}
233 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
233234 errorExpanded={props.errorExpanded}
234235 label={props.environment.environmentLabel}
235236 machine={resolveEnvironmentMachineKind(serverConfig)}
236237 onValueChange={props.onSetEnabled}
237238 onToggleError={props.onToggleError}
238 {...(enabled ? {} : { statusText: "Off" })}
239 disabled={unsupported}
240 {...(enabled || unsupported ? {} : { statusText: "Off" })}
239241 value={enabled}
240242 />
241243 </Pressable>
......@@ -270,6 +272,7 @@ function CloudEnvironmentRow(props: {
270272 }
271273 }}
272274 onToggleError={props.onToggleError}
275 disabled={presentation.connectionState === "unsupported"}
273276 statusText={presentation.statusText}
274277 value={false}
275278 />
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+7-4
......@@ -19,7 +19,7 @@ import { serverEnvironment } from "../../state/server";
1919import { ConnectionStatusDot } from "./ConnectionStatusDot";
2020
2121function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
22 if (!environment.isEnabled) {
22 if (!environment.isEnabled && environment.connectionState !== "unsupported") {
2323 return "Off";
2424 }
2525 return connectionStatusText({
......@@ -46,10 +46,12 @@ export function ConnectionEnvironmentRow(props: {
4646 const serverConfig = useAtomValue(
4747 serverEnvironment.configValueAtom(props.environment.environmentId),
4848 );
49 const enabled = props.environment.isEnabled;
49 const unsupported = props.environment.connectionState === "unsupported";
50 const enabled = props.environment.isEnabled && !unsupported;
5051 const statusLabel = connectionStatusLabel(props.environment);
5152 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;
5355 const isRetrying =
5456 enabled &&
5557 (props.environment.connectionState === "connecting" ||
......@@ -77,7 +79,7 @@ export function ConnectionEnvironmentRow(props: {
7779 onPress={props.onToggle}
7880 >
7981 <ConnectionStatusDot
80 state={enabled ? props.environment.connectionState : "available"}
82 state={enabled || unsupported ? props.environment.connectionState : "available"}
8183 pulse={isRetrying}
8284 size={8}
8385 />
......@@ -133,6 +135,7 @@ export function ConnectionEnvironmentRow(props: {
133135 </View>
134136
135137 <ThemedSwitch
138 disabled={unsupported}
136139 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
137140 value={enabled}
138141 />
apps/mobile/src/state/use-remote-environment-registry.ts+10-1
......@@ -128,7 +128,16 @@ export function useRemoteConnections() {
128128 const error = Cause.squash(result.cause);
129129 const message =
130130 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 }
132141 } else {
133142 appAtomRegistry.set(connectionPairingUrlAtom, "");
134143 }
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+79-1
......@@ -1,6 +1,6 @@
11import type { Discovery } from "@t3tools/client-runtime/relay";
22import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import { EnvironmentId } from "@t3tools/contracts";
3import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
44import * as Option from "effect/Option";
55import { AsyncResult } from "effect/unstable/reactivity";
66import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
......@@ -178,6 +178,84 @@ describe("cloud onboarding discovery", () => {
178178 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
179179 });
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
181259 it("connects and selects discovered computers by default without overwriting deselection", async () => {
182260 discovery.listEnvironments.mockResolvedValue(linkedMachines);
183261 const autoSelectedComputers = new Set<EnvironmentId>();
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+44-8
......@@ -3,13 +3,17 @@ import {
33 type EnvironmentConnectionPresentation,
44 RelayConnectionRegistration,
55 RelayConnectionTarget,
6 orchestrationProtocolCompatibilityError,
67} from "@t3tools/client-runtime/connection";
78import {
89 isAtomCommandInterrupted,
910 squashAtomCommandFailure,
1011} from "@t3tools/client-runtime/state/runtime";
1112import type { EnvironmentId } from "@t3tools/contracts";
12import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
13import type {
14 RelayClientEnvironmentRecord,
15 RelayEnvironmentStatusResponse,
16} from "@t3tools/contracts/relay";
1317import * as Option from "effect/Option";
1418import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";
1519
......@@ -29,6 +33,13 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec
2933
3034const 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
3243export interface SavedCloudEnvironmentConnection {
3344 readonly environmentId: EnvironmentId;
3445 readonly connection: EnvironmentConnectionPresentation;
......@@ -119,6 +130,12 @@ export function CloudEnvironmentConnectRows({
119130 }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);
120131
121132 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
133 if (
134 discoveredCompatibilityError(
135 environmentsState.environments.get(environment.environmentId)?.status,
136 ) !== null
137 )
138 return false;
122139 setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));
123140 const result = await connectRelayEnvironment(environment);
124141 setConnectingEnvironmentIds((current) => {
......@@ -166,8 +183,17 @@ export function CloudEnvironmentConnectRows({
166183 const selectNewComputers = useEffectEvent(() => {
167184 const seen = selection?.autoSelectedComputers;
168185 if (!selection || !seen) return;
169 for (const { environment } of visibleEnvironments) {
186 for (const { environment, status, availability } of visibleEnvironments) {
170187 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 }
171197 if (seen.has(id)) continue;
172198 seen.add(id);
173199 selection.onChange(id, true);
......@@ -265,11 +291,20 @@ export function CloudEnvironmentConnectRows({
265291 return empty;
266292 }
267293
268 return visibleEnvironments.map(({ environment, availability, error }) => {
294 return visibleEnvironments.map(({ environment, availability, error, status }) => {
269295 const savedEnvironment = savedById.get(environment.environmentId);
270 const savedConnection = savedEnvironment
271 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
272 : null;
296 const compatibilityError = discoveredCompatibilityError(status);
297 const unsupported =
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;
273308 const dotClassName = savedConnection
274309 ? savedConnection.tone === "connected"
275310 ? "bg-success"
......@@ -302,9 +337,10 @@ export function CloudEnvironmentConnectRows({
302337 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 has-disabled:cursor-default"
303338 >
304339 <Checkbox
305 checked={selection.selectedIds.has(environment.environmentId)}
306 disabled={connectingEnvironmentIds.has(environment.environmentId)}
340 checked={!unsupported && selection.selectedIds.has(environment.environmentId)}
341 disabled={unsupported || connectingEnvironmentIds.has(environment.environmentId)}
307342 onCheckedChange={async (checked) => {
343 if (unsupported) return;
308344 selection.onChange(environment.environmentId, checked);
309345 if (checked && !savedEnvironment) {
310346 const connected = await connectEnvironment(environment);
apps/web/src/components/settings/ConnectionsSettings.tsx+13-6
......@@ -1444,7 +1444,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
14441444 readonly text: string;
14451445 readonly tone: "muted" | "error";
14461446} {
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" };
14481449 const { connection } = environment;
14491450 switch (connection.phase) {
14501451 case "connected":
......@@ -1482,7 +1483,8 @@ function SavedBackendListRow({
14821483 onRemove,
14831484}: SavedBackendListRowProps) {
14841485 const environmentId = environment.environmentId;
1485 const enabled = environment.entry.enabled;
1486 const unsupported = environment.connection.phase === "unsupported";
1487 const enabled = environment.entry.enabled && !unsupported;
14861488 const isConnected = environment.connection.phase === "connected";
14871489 const isRemoving = removingEnvironmentId === environmentId;
14881490 const errorTraceId = environment.connection.traceId;
......@@ -1545,7 +1547,10 @@ function SavedBackendListRow({
15451547 <span
15461548 className={cn(
15471549 "block truncate",
1548 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",
1550 (enabled || unsupported) &&
1551 status.tone === "error" &&
1552 !resumingServerUpdate &&
1553 "text-destructive",
15491554 )}
15501555 />
15511556 }
......@@ -1553,7 +1558,7 @@ function SavedBackendListRow({
15531558 {subtitleText}
15541559 </TooltipTrigger>
15551560 <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"}
15571562 {versionMismatch
15581563 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
15591564 : ""}
......@@ -1586,13 +1591,15 @@ function SavedBackendListRow({
15861591 <Switch
15871592 size="sm"
15881593 checked={enabled}
1589 disabled={isRemoving}
1594 disabled={isRemoving || unsupported}
15901595 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
15911596 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
15921597 />
15931598 }
15941599 />
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>
15961603 </Tooltip>
15971604 <Menu>
15981605 <MenuTrigger
packages/client-runtime/src/connection/catalog.ts+2
......@@ -41,6 +41,8 @@ export interface ConnectionCatalogEntry {
4141 readonly profile: Option.Option<ConnectionProfile>;
4242 /** False when the user switched the environment off: saved, but never connects. */
4343 readonly enabled: boolean;
44 /** Discovery rejection stays visible while the saved connection is switched off. */
45 readonly unsupportedReason?: string;
4446}
4547
4648export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
packages/client-runtime/src/connection/index.ts+2
......@@ -21,3 +21,5 @@ export {
2121} from "./registry.ts";
2222export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts";
2323export * as Wakeups from "./wakeups.ts";
24
25export { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
packages/client-runtime/src/connection/layer.ts+48
......@@ -1,6 +1,10 @@
1import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
12import * as Effect from "effect/Effect";
23import * as Layer from "effect/Layer";
34import * as Stream from "effect/Stream";
5import * as Option from "effect/Option";
6import * as SubscriptionRef from "effect/SubscriptionRef";
7import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
48
59import * as ConnectionResolver from "./resolver.ts";
610import * as ConnectionDriver from "./driver.ts";
......@@ -11,6 +15,49 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
1115import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1216import * 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
1461export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
1562 const driverLayer = ConnectionDriver.layer.pipe(
1663 Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))),
......@@ -26,6 +73,7 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
2673 Effect.gen(function* () {
2774 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
2875 const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;
76 yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped);
2977 yield* registry.start;
3078 yield* platformSource.registrations.pipe(
3179 Stream.runForEach(registry.reconcilePlatform),
packages/client-runtime/src/connection/onboarding.test.ts+29-2
......@@ -1,4 +1,8 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
1import {
2 AuthStandardClientScopes,
3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_VERSION,
5} from "@t3tools/contracts";
26import { describe, expect, it } from "@effect/vitest";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
......@@ -28,7 +32,7 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed(
2832
2933function pairingHttpLayer(
3034 calls: Array<{ readonly url: string; readonly init: RequestInit }>,
31 options?: { readonly failDescriptor?: boolean },
35 options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number },
3236) {
3337 const fetchFn = ((input, init = {}) => {
3438 const url = String(input);
......@@ -49,6 +53,7 @@ function pairingHttpLayer(
4953 arch: "x64",
5054 },
5155 serverVersion: "0.0.0-test",
56 orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION,
5257 capabilities: {
5358 repositoryIdentity: true,
5459 },
......@@ -118,6 +123,28 @@ describe("connection onboarding", () => {
118123 }),
119124 );
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
121148 it.effect("does not consume a pairing credential when descriptor discovery fails", () =>
122149 Effect.gen(function* () {
123150 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
packages/client-runtime/src/connection/onboarding.ts+3
......@@ -31,6 +31,7 @@ import {
3131} from "./model.ts";
3232import * as Persistence from "../platform/persistence.ts";
3333import * as EnvironmentRegistry from "./registry.ts";
34import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
3435
3536export interface PairingConnectionInput {
3637 readonly pairingUrl?: string;
......@@ -91,6 +92,8 @@ export const preparePairingRegistration = Effect.fn(
9192 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
9293 httpBaseUrl: target.httpBaseUrl,
9394 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
95 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
96 if (compatibilityError !== null) return yield* compatibilityError;
9497 const access = yield* bootstrapRemoteBearerSession({
9598 httpBaseUrl: target.httpBaseUrl,
9699 credential: target.credential,
packages/client-runtime/src/connection/registry.test.ts+263
......@@ -1,6 +1,8 @@
11import {
22 type DesktopSshEnvironmentTarget,
33 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_VERSION,
5 type ExecutionEnvironmentDescriptor,
46 type OrchestrationShellSnapshot,
57} from "@t3tools/contracts";
68import { describe, expect, it } from "@effect/vitest";
......@@ -36,6 +38,7 @@ import * as ConnectionCredentialStore from "./credentialStore.ts";
3638import * as ConnectionDriver from "./driver.ts";
3739import {
3840 ConnectionTransientError,
41 ConnectionBlockedError,
3942 BearerConnectionTarget,
4043 PrimaryConnectionTarget,
4144 RelayConnectionTarget,
......@@ -54,6 +57,9 @@ import {
5457import * as RpcSession from "../rpc/session.ts";
5558import * as EnvironmentSupervisor from "./supervisor.ts";
5659import * as ConnectionWakeups from "./wakeups.ts";
60import { watchDiscoveredCompatibility } from "./layer.ts";
61import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
62import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
5763import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
5864
5965const TARGET = new PrimaryConnectionTarget({
......@@ -137,6 +143,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
137143 initialProfiles: ReadonlyArray<ConnectionProfile> = [],
138144 initialCredentials: ReadonlyArray<readonly [string, ConnectionCredential]> = [],
139145 options?: {
146 readonly prepareError?: ConnectionBlockedError;
140147 readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>;
141148 readonly beforeRegistrationRegister?: (
142149 registration: ConnectionRegistration,
......@@ -368,6 +375,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
368375 target,
369376 };
370377 yield* reportProgress({ stage: "preparing" });
378 if (options?.prepareError) return yield* options.prepareError;
371379 yield* reportProgress({ stage: "opening", prepared });
372380 yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void;
373381 const closed = yield* Deferred.make<never, ConnectionTransientError>();
......@@ -673,6 +681,212 @@ describe("EnvironmentRegistry", () => {
673681 }),
674682 );
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
676890 it.effect("switching an environment off disconnects it and persists the flag", () =>
677891 Effect.gen(function* () {
678892 const harness = yield* makeHarness([RELAY_TARGET]);
......@@ -1095,6 +1309,55 @@ describe("EnvironmentRegistry", () => {
10951309 }),
10961310 );
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
10981361 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>
10991362 Effect.gen(function* () {
11001363 const harness = yield* makeHarness([]);
packages/client-runtime/src/connection/registry.ts+81-4
......@@ -30,6 +30,7 @@ import type {
3030 NetworkStatus,
3131 SupervisorConnectionState,
3232} from "./model.ts";
33import { ConnectionBlockedError } from "./model.ts";
3334import * as Persistence from "../platform/persistence.ts";
3435import * as EnvironmentSupervisor from "./supervisor.ts";
3536import * as ConnectionDriver from "./driver.ts";
......@@ -104,8 +105,14 @@ export class EnvironmentRegistry extends Context.Service<
104105 enabled: boolean,
105106 ) => Effect.Effect<
106107 void,
107 EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError
108 | EnvironmentNotRegisteredError
109 | Persistence.ConnectionPersistenceError
110 | ConnectionBlockedError
108111 >;
112 readonly setCompatibility: (
113 environmentId: EnvironmentId,
114 error: ConnectionBlockedError | null,
115 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
109116 readonly state: (
110117 environmentId: EnvironmentId,
111118 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
......@@ -291,6 +298,21 @@ export const make = Effect.gen(function* () {
291298 next.set(environmentId, { entry, supervisor, scope });
292299 return next;
293300 });
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 );
294316 return supervisor;
295317 }),
296318 ),
......@@ -426,7 +448,16 @@ export const make = Effect.gen(function* () {
426448 // Editing a saved environment must preserve its disabled state.
427449 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
428450 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 };
430461 if (
431462 previous !== undefined &&
432463 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)
......@@ -454,12 +485,17 @@ export const make = Effect.gen(function* () {
454485
455486 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(
456487 function* (registration: PlatformConnectionRegistration) {
457 const entry = connectionRegistrationCatalogEntry(registration);
458 const target = entry.target;
488 const registered = connectionRegistrationCatalogEntry(registration);
489 const target = registered.target;
459490 yield* withLeaseLock(
460491 target.environmentId,
461492 Effect.gen(function* () {
462493 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;
463499 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
464500 target.environmentId,
465501 );
......@@ -714,6 +750,12 @@ export const make = Effect.gen(function* () {
714750 environmentId,
715751 Effect.gen(function* () {
716752 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 }
717759 if (entry.enabled === enabled) {
718760 return;
719761 }
......@@ -794,6 +836,40 @@ export const make = Effect.gen(function* () {
794836 Effect.forkScoped,
795837 );
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
797873 return EnvironmentRegistry.of({
798874 entries,
799875 networkStatus,
......@@ -805,6 +881,7 @@ export const make = Effect.gen(function* () {
805881 removeRelayEnvironments,
806882 retryNow,
807883 setEnabled,
884 setCompatibility,
808885 state,
809886 stateChanges,
810887 run,
packages/client-runtime/src/state/presentation.ts+4-1
......@@ -41,7 +41,10 @@ export function createEnvironmentPresentationAtoms<E>(input: {
4141 );
4242 return {
4343 entry,
44 connection: presentEnvironmentConnection(state),
44 connection:
45 entry.unsupportedReason === undefined
46 ? presentEnvironmentConnection(state)
47 : { phase: "unsupported", error: entry.unsupportedReason, traceId: null },
4548 serverConfig: get(input.serverConfigValueAtom(environmentId)),
4649 } satisfies EnvironmentPresentation;
4750 }).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?
182182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
183183 retryNow: () => Effect.void,
184184 setEnabled: () => Effect.die("Unexpected environment toggle"),
185 setCompatibility: () => Effect.die("Unexpected compatibility update"),
185186 state: () => SubscriptionRef.get(supervisor.state),
186187 stateChanges: () => SubscriptionRef.changes(supervisor.state),
187188 run: (_environmentId, effect) =>