| author | |
| committer | |
| log | 22e9d35613305a04a28e96f83abc10c1bcf4aa8a |
| tree | 6fe9dcf59d57d7ab29f6adec329203cfd610d05d |
| parent | c91e93c231e7ae6ec993f8496120ef04dd5fe24d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>18 files changed, 784 insertions(+), 20 deletions(-)
apps/web/src/components/ServerUpdateAction.tsx+54-1| ... | @@ -10,7 +10,7 @@ import { type ComponentProps, useRef, useState } from "react"; | ... | @@ -10,7 +10,7 @@ import { type ComponentProps, useRef, useState } from "react"; |
| 10 | import { requestConfirmDialog } from "~/confirmDialog"; | 10 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 11 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 11 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 12 | import { useEnvironmentSettings } from "~/hooks/useSettings"; | 12 | import { useEnvironmentSettings } from "~/hooks/useSettings"; |
| 13 | import { serverEnvironment } from "~/state/server"; | 13 | import { serverEnvironment, updateOutdatedServer } from "~/state/server"; |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | 14 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 15 | import { manualServerUpdateCommand } from "~/versionSkew"; | 15 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 16 | import { Button } from "./ui/button"; | 16 | import { Button } from "./ui/button"; |
| ... | @@ -290,3 +290,56 @@ export function ServerUpdateAction({ | ... | @@ -290,3 +290,56 @@ export function ServerUpdateAction({ |
| 290 | </Button> | 290 | </Button> |
| 291 | ); | 291 | ); |
| 292 | } | 292 | } |
| 293 | |||
| 294 | /** | ||
| 295 | * Updates a host too old for this client to connect to. Its version comes | ||
| 296 | * from the host descriptor because the host never delivers a server config. | ||
| 297 | */ | ||
| 298 | export function OutdatedServerUpdateAction({ | ||
| 299 | environmentId, | ||
| 300 | serverLabel, | ||
| 301 | fromVersion, | ||
| 302 | targetVersion, | ||
| 303 | label = "Update", | ||
| 304 | }: { | ||
| 305 | readonly environmentId: EnvironmentId; | ||
| 306 | readonly serverLabel: string; | ||
| 307 | readonly fromVersion: string | undefined; | ||
| 308 | readonly targetVersion: string; | ||
| 309 | readonly label?: string; | ||
| 310 | }) { | ||
| 311 | const update = useAtomCommand(updateOutdatedServer, { reportFailure: false }); | ||
| 312 | const handleUpdate = async () => { | ||
| 313 | if (pendingUpdateEnvironmentIds.has(environmentId)) return; | ||
| 314 | pendingUpdateEnvironmentIds.add(environmentId); | ||
| 315 | try { | ||
| 316 | const result = await update({ | ||
| 317 | environmentId, | ||
| 318 | input: { targetVersion }, | ||
| 319 | ...(fromVersion === undefined ? {} : { fromVersion }), | ||
| 320 | }); | ||
| 321 | if (result._tag === "Failure") { | ||
| 322 | if (isAtomCommandInterrupted(result)) return; | ||
| 323 | throw squashAtomCommandFailure(result); | ||
| 324 | } | ||
| 325 | toastManager.add({ | ||
| 326 | type: "success", | ||
| 327 | title: `${serverLabel} updated`, | ||
| 328 | description: `Reconnected on t3@${result.value.targetVersion}.`, | ||
| 329 | }); | ||
| 330 | } catch (error) { | ||
| 331 | toastManager.add({ | ||
| 332 | type: "error", | ||
| 333 | title: "Server update failed", | ||
| 334 | description: updateFailureMessage(error), | ||
| 335 | }); | ||
| 336 | } finally { | ||
| 337 | pendingUpdateEnvironmentIds.delete(environmentId); | ||
| 338 | } | ||
| 339 | }; | ||
| 340 | return ( | ||
| 341 | <Button size="xs" variant="outline" onClick={() => void handleUpdate()}> | ||
| 342 | {label} | ||
| 343 | </Button> | ||
| 344 | ); | ||
| 345 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+13| ... | @@ -165,11 +165,13 @@ import { | ... | @@ -165,11 +165,13 @@ import { |
| 165 | usePrimaryEnvironment, | 165 | usePrimaryEnvironment, |
| 166 | useRelayEnvironmentDiscovery, | 166 | useRelayEnvironmentDiscovery, |
| 167 | } from "~/state/environments"; | 167 | } from "~/state/environments"; |
| 168 | import { APP_VERSION } from "~/branding"; | ||
| 168 | import { requestConfirmDialog } from "~/confirmDialog"; | 169 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 169 | import { useAtomCommand } from "../../state/use-atom-command"; | 170 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 170 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; | 171 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; |
| 171 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | 172 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 172 | import { | 173 | import { |
| 174 | OutdatedServerUpdateAction, | ||
| 173 | ServerUpdateAction, | 175 | ServerUpdateAction, |
| 174 | ServerUpdateProgress, | 176 | ServerUpdateProgress, |
| 175 | ServerUpdatesAction, | 177 | ServerUpdatesAction, |
| ... | @@ -1598,6 +1600,17 @@ function SavedBackendListRow({ | ... | @@ -1598,6 +1600,17 @@ function SavedBackendListRow({ |
| 1598 | ) : null | 1600 | ) : null |
| 1599 | } | 1601 | } |
| 1600 | > | 1602 | > |
| 1603 | {unsupported && | ||
| 1604 | environment.entry.serverUpdateRequired === true && | ||
| 1605 | serverUpdateState.status !== "running" ? ( | ||
| 1606 | <OutdatedServerUpdateAction | ||
| 1607 | environmentId={environmentId} | ||
| 1608 | serverLabel={`${environment.label} server`} | ||
| 1609 | fromVersion={lastDescriptor?.serverVersion} | ||
| 1610 | targetVersion={APP_VERSION} | ||
| 1611 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} | ||
| 1612 | /> | ||
| 1613 | ) : null} | ||
| 1601 | {showUpdateAction ? ( | 1614 | {showUpdateAction ? ( |
| 1602 | <ServerUpdateAction | 1615 | <ServerUpdateAction |
| 1603 | environmentId={environmentId} | 1616 | environmentId={environmentId} |
apps/web/src/state/server.ts+3| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type ServerSettings, | 10 | type ServerSettings, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; | 12 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; |
| 13 | import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate"; | ||
| 13 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; | 14 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; |
| 14 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; | 15 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; |
| 15 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| ... | @@ -32,6 +33,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt | ... | @@ -32,6 +33,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt |
| 32 | usageLimitSources: true, | 33 | usageLimitSources: true, |
| 33 | usageLimitsCommand: true, | 34 | usageLimitsCommand: true, |
| 34 | }); | 35 | }); |
| 36 | /** Updates a host whose protocol is too old for this client to connect to. */ | ||
| 37 | export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime); | ||
| 35 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | 38 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 36 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 39 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 37 | serverConfigValueAtom: serverEnvironment.configValueAtom, | 40 | serverConfigValueAtom: serverEnvironment.configValueAtom, |
packages/client-runtime/package.json+4| ... | @@ -219,6 +219,10 @@ | ... | @@ -219,6 +219,10 @@ |
| 219 | "types": "./src/state/server.ts", | 219 | "types": "./src/state/server.ts", |
| 220 | "default": "./src/state/server.ts" | 220 | "default": "./src/state/server.ts" |
| 221 | }, | 221 | }, |
| 222 | "./state/outdatedServerUpdate": { | ||
| 223 | "types": "./src/state/outdatedServerUpdate.ts", | ||
| 224 | "default": "./src/state/outdatedServerUpdate.ts" | ||
| 225 | }, | ||
| 222 | "./state/session": { | 226 | "./state/session": { |
| 223 | "types": "./src/state/session.ts", | 227 | "types": "./src/state/session.ts", |
| 224 | "default": "./src/state/session.ts" | 228 | "default": "./src/state/session.ts" |
packages/client-runtime/src/connection/catalog.ts+2| ... | @@ -43,6 +43,8 @@ export interface ConnectionCatalogEntry { | ... | @@ -43,6 +43,8 @@ export interface ConnectionCatalogEntry { |
| 43 | readonly enabled: boolean; | 43 | readonly enabled: boolean; |
| 44 | /** Discovery rejection stays visible while the saved connection is switched off. */ | 44 | /** Discovery rejection stays visible while the saved connection is switched off. */ |
| 45 | readonly unsupportedReason?: string; | 45 | readonly unsupportedReason?: string; |
| 46 | /** The rejection came from an outdated host, which can still be updated remotely. */ | ||
| 47 | readonly serverUpdateRequired?: boolean; | ||
| 46 | } | 48 | } |
| 47 | 49 | ||
| 48 | export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()( | 50 | export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()( |
packages/client-runtime/src/connection/compatibility.test.ts+24| ... | @@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => { | ... | @@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => { |
| 50 | ); | 50 | ); |
| 51 | expect(error).toMatchObject({ reason: "unsupported" }); | 51 | expect(error).toMatchObject({ reason: "unsupported" }); |
| 52 | expect(error?.message).toContain("This client is not supported"); | 52 | expect(error?.message).toContain("This client is not supported"); |
| 53 | expect(error).not.toHaveProperty("serverUpdateRequired"); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("offers a remote update only for an older host that can update itself", () => { | ||
| 57 | const older = descriptor(ORCHESTRATION_PROTOCOL_VERSION - 1); | ||
| 58 | const withCapabilities = (capabilities: ExecutionEnvironmentDescriptor["capabilities"]) => | ||
| 59 | orchestrationProtocolCompatibilityError({ ...older, capabilities }); | ||
| 60 | |||
| 61 | expect( | ||
| 62 | withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "boot-service" }), | ||
| 63 | ).toMatchObject({ serverUpdateRequired: true }); | ||
| 64 | expect(withCapabilities({ repositoryIdentity: true })).not.toHaveProperty( | ||
| 65 | "serverUpdateRequired", | ||
| 66 | ); | ||
| 67 | expect( | ||
| 68 | withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }), | ||
| 69 | ).not.toHaveProperty("serverUpdateRequired"); | ||
| 70 | expect( | ||
| 71 | withCapabilities({ | ||
| 72 | repositoryIdentity: true, | ||
| 73 | serverSelfUpdate: "desktop-managed", | ||
| 74 | desktopAppUpdate: true, | ||
| 75 | }), | ||
| 76 | ).toMatchObject({ serverUpdateRequired: true }); | ||
| 53 | }); | 77 | }); |
| 54 | }); | 78 | }); |
packages/client-runtime/src/connection/compatibility.ts+19-7| ... | @@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError( | ... | @@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError( |
| 14 | if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { | 14 | if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { |
| 15 | return null; | 15 | return null; |
| 16 | } | 16 | } |
| 17 | return new ConnectionBlockedError({ | 17 | return serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION |
| 18 | reason: "unsupported", | 18 | ? new ConnectionBlockedError({ |
| 19 | detail: | 19 | reason: "unsupported", |
| 20 | serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION | 20 | detail: `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`, |
| 21 | ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.` | 21 | }) |
| 22 | : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, | 22 | : new ConnectionBlockedError({ |
| 23 | }); | 23 | reason: "unsupported", |
| 24 | detail: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, | ||
| 25 | ...(canSelfUpdate(descriptor) ? { serverUpdateRequired: true } : {}), | ||
| 26 | }); | ||
| 27 | } | ||
| 28 | |||
| 29 | /** Whether this client can drive the host's update remotely. */ | ||
| 30 | function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { | ||
| 31 | const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities; | ||
| 32 | return ( | ||
| 33 | serverSelfUpdate !== undefined && | ||
| 34 | (serverSelfUpdate !== "desktop-managed" || desktopAppUpdate === true) | ||
| 35 | ); | ||
| 24 | } | 36 | } |
| 25 | 37 | ||
| 26 | export function appendOrchestrationProtocol(socketUrl: string): string { | 38 | export function appendOrchestrationProtocol(socketUrl: string): string { |
packages/client-runtime/src/connection/index.ts+2| ... | @@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts"; | ... | @@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts"; |
| 16 | export * as Wakeups from "./wakeups.ts"; | 16 | export * as Wakeups from "./wakeups.ts"; |
| 17 | 17 | ||
| 18 | export { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; | 18 | export { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; |
| 19 | // Flat so consumers' inferred command types can name it. | ||
| 20 | export { OutdatedHostUpdateError } from "./outdatedHostUpdate.ts"; |
packages/client-runtime/src/connection/layer.ts+2| ... | @@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { | ... | @@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 75 | registryLayer, | 75 | registryLayer, |
| 76 | RelayEnvironmentDiscovery.layer, | 76 | RelayEnvironmentDiscovery.layer, |
| 77 | onboardingLayer, | 77 | onboardingLayer, |
| 78 | // Exposed for updating hosts too old to connect through the driver. | ||
| 79 | ConnectionResolver.layer, | ||
| 78 | ); | 80 | ); |
| 79 | const connectionStartupLayer = Layer.effectDiscard( | 81 | const connectionStartupLayer = Layer.effectDiscard( |
| 80 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
packages/client-runtime/src/connection/model.ts+2| ... | @@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError<ConnectionBlocked | ... | @@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError<ConnectionBlocked |
| 94 | reason: ConnectionBlockedReason, | 94 | reason: ConnectionBlockedReason, |
| 95 | detail: Schema.String, | 95 | detail: Schema.String, |
| 96 | traceId: Schema.optionalKey(Schema.String), | 96 | traceId: Schema.optionalKey(Schema.String), |
| 97 | /** The host speaks an older orchestration protocol; updating it restores the connection. */ | ||
| 98 | serverUpdateRequired: Schema.optionalKey(Schema.Boolean), | ||
| 97 | }, | 99 | }, |
| 98 | ) { | 100 | ) { |
| 99 | override get message(): string { | 101 | override get message(): string { |
packages/client-runtime/src/connection/onboarding.test.ts+51-1| ... | @@ -32,7 +32,11 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed( | ... | @@ -32,7 +32,11 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed( |
| 32 | 32 | ||
| 33 | function pairingHttpLayer( | 33 | function pairingHttpLayer( |
| 34 | calls: Array<{ readonly url: string; readonly init: RequestInit }>, | 34 | calls: Array<{ readonly url: string; readonly init: RequestInit }>, |
| 35 | options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number }, | 35 | options?: { |
| 36 | readonly failDescriptor?: boolean; | ||
| 37 | readonly protocolVersion?: number; | ||
| 38 | readonly selfUpdate?: boolean; | ||
| 39 | }, | ||
| 36 | ) { | 40 | ) { |
| 37 | const fetchFn = ((input, init = {}) => { | 41 | const fetchFn = ((input, init = {}) => { |
| 38 | const url = String(input); | 42 | const url = String(input); |
| ... | @@ -56,6 +60,7 @@ function pairingHttpLayer( | ... | @@ -56,6 +60,7 @@ function pairingHttpLayer( |
| 56 | orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION, | 60 | orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION, |
| 57 | capabilities: { | 61 | capabilities: { |
| 58 | repositoryIdentity: true, | 62 | repositoryIdentity: true, |
| 63 | ...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}), | ||
| 59 | }, | 64 | }, |
| 60 | }), | 65 | }), |
| 61 | ); | 66 | ); |
| ... | @@ -145,6 +150,51 @@ describe("connection onboarding", () => { | ... | @@ -145,6 +150,51 @@ describe("connection onboarding", () => { |
| 145 | }), | 150 | }), |
| 146 | ); | 151 | ); |
| 147 | 152 | ||
| 153 | it.effect("pairs an outdated server so it can be updated from this client", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 156 | const registration = yield* preparePairingRegistration({ | ||
| 157 | host: "remote.example.test", | ||
| 158 | pairingCode: "pairing-token", | ||
| 159 | }).pipe( | ||
| 160 | Effect.provide( | ||
| 161 | Layer.mergeAll( | ||
| 162 | CLIENT_PRESENTATION_LAYER, | ||
| 163 | pairingHttpLayer(calls, { | ||
| 164 | protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1, | ||
| 165 | selfUpdate: true, | ||
| 166 | }), | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | expect(registration.target.environmentId).toBe("environment-paired"); | ||
| 171 | expect(calls.map((call) => call.url)).toContain("https://remote.example.test/oauth/token"); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("refuses an outdated server that cannot update itself", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 178 | const error = yield* preparePairingRegistration({ | ||
| 179 | host: "remote.example.test", | ||
| 180 | pairingCode: "pairing-token", | ||
| 181 | }).pipe( | ||
| 182 | Effect.provide( | ||
| 183 | Layer.mergeAll( | ||
| 184 | CLIENT_PRESENTATION_LAYER, | ||
| 185 | pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | Effect.flip, | ||
| 189 | ); | ||
| 190 | expect(error).toMatchObject({ reason: "unsupported" }); | ||
| 191 | expect(error).not.toHaveProperty("serverUpdateRequired"); | ||
| 192 | expect(calls.map((call) => call.url)).toEqual([ | ||
| 193 | "https://remote.example.test/.well-known/t3/environment", | ||
| 194 | ]); | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | |||
| 148 | it.effect("does not consume a pairing credential when descriptor discovery fails", () => | 198 | it.effect("does not consume a pairing credential when descriptor discovery fails", () => |
| 149 | Effect.gen(function* () { | 199 | Effect.gen(function* () { |
| 150 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | 200 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; |
packages/client-runtime/src/connection/onboarding.ts+4-1| ... | @@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn( | ... | @@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn( |
| 93 | httpBaseUrl: target.httpBaseUrl, | 93 | httpBaseUrl: target.httpBaseUrl, |
| 94 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); | 94 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); |
| 95 | const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); | 95 | const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); |
| 96 | if (compatibilityError !== null) return yield* compatibilityError; | 96 | // An outdated server is still saved so it can be updated from this client. |
| 97 | if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { | ||
| 98 | return yield* compatibilityError; | ||
| 99 | } | ||
| 97 | const access = yield* bootstrapRemoteBearerSession({ | 100 | const access = yield* bootstrapRemoteBearerSession({ |
| 98 | httpBaseUrl: target.httpBaseUrl, | 101 | httpBaseUrl: target.httpBaseUrl, |
| 99 | credential: target.credential, | 102 | credential: target.credential, |
packages/client-runtime/src/connection/outdatedHostUpdate.test.ts created+278| ... | @@ -0,0 +1,278 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 4 | ExecutionEnvironmentDescriptor, | ||
| 5 | WS_METHODS, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 13 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 14 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | ||
| 15 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 16 | |||
| 17 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 18 | import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; | ||
| 19 | import { PrimaryConnectionTarget } from "./model.ts"; | ||
| 20 | import { updateOutdatedHost } from "./outdatedHostUpdate.ts"; | ||
| 21 | import * as EnvironmentRegistry from "./registry.ts"; | ||
| 22 | import * as ConnectionResolver from "./resolver.ts"; | ||
| 23 | import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; | ||
| 24 | |||
| 25 | const TARGET = new PrimaryConnectionTarget({ | ||
| 26 | environmentId: EnvironmentId.make("environment-old"), | ||
| 27 | label: "Build Mac", | ||
| 28 | httpBaseUrl: "https://build.example.test", | ||
| 29 | wsBaseUrl: "wss://build.example.test", | ||
| 30 | }); | ||
| 31 | |||
| 32 | const descriptor = (protocol: number | undefined, serverVersion: string) => | ||
| 33 | ({ | ||
| 34 | environmentId: TARGET.environmentId, | ||
| 35 | label: TARGET.label, | ||
| 36 | platform: { os: "darwin", arch: "arm64" }, | ||
| 37 | serverVersion, | ||
| 38 | ...(protocol === undefined ? {} : { orchestrationProtocolVersion: protocol }), | ||
| 39 | capabilities: { repositoryIdentity: true, serverSelfUpdate: "boot-service" }, | ||
| 40 | }) satisfies ExecutionEnvironmentDescriptor; | ||
| 41 | |||
| 42 | const RpcRequest = Schema.TaggedStruct("Request", { | ||
| 43 | id: Schema.Union([Schema.String, Schema.Number]), | ||
| 44 | payload: Schema.Unknown, | ||
| 45 | tag: Schema.String, | ||
| 46 | }); | ||
| 47 | const isRpcRequest = Schema.is(RpcRequest); | ||
| 48 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 49 | const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 50 | const encodeDescriptor = Schema.encodeSync(Schema.fromJsonString(ExecutionEnvironmentDescriptor)); | ||
| 51 | |||
| 52 | type Listener = (event: { readonly type: string; readonly data?: unknown }) => void; | ||
| 53 | |||
| 54 | /** Answers update RPCs the way a protocol-1 server does. */ | ||
| 55 | class OutdatedHostSocket { | ||
| 56 | static readonly OPEN = 1; | ||
| 57 | readyState = 0; | ||
| 58 | readonly requests: Array<typeof RpcRequest.Type> = []; | ||
| 59 | private readonly listeners = new Map<string, Set<Listener>>(); | ||
| 60 | |||
| 61 | readonly url: string; | ||
| 62 | private readonly onUpdate: () => void; | ||
| 63 | |||
| 64 | constructor(url: string, onUpdate: () => void) { | ||
| 65 | this.url = url; | ||
| 66 | this.onUpdate = onUpdate; | ||
| 67 | queueMicrotask(() => { | ||
| 68 | this.readyState = OutdatedHostSocket.OPEN; | ||
| 69 | this.emit({ type: "open" }); | ||
| 70 | }); | ||
| 71 | } | ||
| 72 | |||
| 73 | addEventListener(type: string, listener: Listener) { | ||
| 74 | const listeners = this.listeners.get(type) ?? new Set<Listener>(); | ||
| 75 | listeners.add(listener); | ||
| 76 | this.listeners.set(type, listeners); | ||
| 77 | } | ||
| 78 | |||
| 79 | removeEventListener(type: string, listener: Listener) { | ||
| 80 | this.listeners.get(type)?.delete(listener); | ||
| 81 | } | ||
| 82 | |||
| 83 | send(data: string) { | ||
| 84 | const message = decodeJson(data); | ||
| 85 | if (!isRpcRequest(message)) return; | ||
| 86 | this.requests.push(message); | ||
| 87 | if (message.tag !== WS_METHODS.serverUpdateServer) return; | ||
| 88 | this.onUpdate(); | ||
| 89 | queueMicrotask(() => | ||
| 90 | this.emit({ | ||
| 91 | type: "message", | ||
| 92 | data: encodeJson({ | ||
| 93 | _tag: "Exit", | ||
| 94 | requestId: message.id, | ||
| 95 | exit: { | ||
| 96 | _tag: "Success", | ||
| 97 | value: { targetVersion: "0.0.46", method: "boot-service", updateId: "update-1" }, | ||
| 98 | }, | ||
| 99 | }), | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | close() { | ||
| 105 | this.readyState = 3; | ||
| 106 | this.emit({ type: "close" }); | ||
| 107 | } | ||
| 108 | |||
| 109 | private emit(event: { readonly type: string; readonly data?: unknown }) { | ||
| 110 | for (const listener of this.listeners.get(event.type) ?? []) listener(event); | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | describe("updateOutdatedHost", () => { | ||
| 115 | it.effect("updates a protocol-1 host over a bare socket, then switches it back on", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45")); | ||
| 118 | expect(blocked).toMatchObject({ serverUpdateRequired: true }); | ||
| 119 | |||
| 120 | const sockets: Array<OutdatedHostSocket> = []; | ||
| 121 | let served: ExecutionEnvironmentDescriptor = descriptor(undefined, "0.0.45"); | ||
| 122 | const entries = yield* SubscriptionRef.make< | ||
| 123 | ReadonlyMap<EnvironmentId, ConnectionCatalogEntry> | ||
| 124 | >( | ||
| 125 | new Map([ | ||
| 126 | [ | ||
| 127 | TARGET.environmentId, | ||
| 128 | { | ||
| 129 | target: TARGET, | ||
| 130 | profile: Option.none(), | ||
| 131 | enabled: false, | ||
| 132 | unsupportedReason: blocked?.message ?? "", | ||
| 133 | serverUpdateRequired: true, | ||
| 134 | }, | ||
| 135 | ], | ||
| 136 | ]), | ||
| 137 | ); | ||
| 138 | const calls: Array<string> = []; | ||
| 139 | const registry = EnvironmentRegistry.EnvironmentRegistry.of({ | ||
| 140 | entries, | ||
| 141 | setCompatibility: (_environmentId: EnvironmentId, error: unknown) => | ||
| 142 | Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)), | ||
| 143 | setEnabled: (_environmentId: EnvironmentId, enabled: boolean) => | ||
| 144 | Effect.sync(() => calls.push(`enabled:${enabled}`)), | ||
| 145 | } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); | ||
| 146 | const resolver = ConnectionResolver.ConnectionResolver.of({ | ||
| 147 | prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")), | ||
| 148 | prepareForUpdate: () => | ||
| 149 | Effect.sync(() => served).pipe( | ||
| 150 | Effect.map((current) => ({ | ||
| 151 | descriptor: current, | ||
| 152 | prepared: { | ||
| 153 | environmentId: TARGET.environmentId, | ||
| 154 | label: TARGET.label, | ||
| 155 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 156 | socketUrl: "wss://build.example.test/ws?wsTicket=ticket", | ||
| 157 | httpAuthorization: null, | ||
| 158 | target: TARGET, | ||
| 159 | }, | ||
| 160 | })), | ||
| 161 | ), | ||
| 162 | }); | ||
| 163 | const httpClient = HttpClient.make((request) => | ||
| 164 | Effect.sync(() => | ||
| 165 | HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | |||
| 169 | const result = yield* updateOutdatedHost( | ||
| 170 | TARGET.environmentId, | ||
| 171 | { targetVersion: "0.0.46" }, | ||
| 172 | () => Effect.void, | ||
| 173 | ).pipe( | ||
| 174 | Effect.provide( | ||
| 175 | Layer.mergeAll( | ||
| 176 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry), | ||
| 177 | Layer.succeed(ConnectionResolver.ConnectionResolver, resolver), | ||
| 178 | Layer.succeed( | ||
| 179 | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, | ||
| 180 | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ | ||
| 181 | state: yield* SubscriptionRef.make( | ||
| 182 | RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, | ||
| 183 | ), | ||
| 184 | refresh: Effect.void, | ||
| 185 | }), | ||
| 186 | ), | ||
| 187 | Layer.succeed(HttpClient.HttpClient, httpClient), | ||
| 188 | Layer.succeed(Socket.WebSocketConstructor, (url) => { | ||
| 189 | // The host relaunches on a compatible protocol once the update lands. | ||
| 190 | const socket = new OutdatedHostSocket(url, () => { | ||
| 191 | served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); | ||
| 192 | }); | ||
| 193 | sockets.push(socket); | ||
| 194 | return socket as unknown as globalThis.WebSocket; | ||
| 195 | }), | ||
| 196 | ), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 200 | expect(sockets[0]?.url).not.toContain("orchestrationProtocol"); | ||
| 201 | expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([ | ||
| 202 | WS_METHODS.serverUpdateServer, | ||
| 203 | ]); | ||
| 204 | expect(result.targetVersion).toBe("0.0.46"); | ||
| 205 | expect(calls).toEqual(["compatibility:clear", "enabled:true"]); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("refuses a host that cannot update itself without opening a socket", () => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const manual = { | ||
| 212 | ...descriptor(undefined, "0.0.45"), | ||
| 213 | capabilities: { repositoryIdentity: true }, | ||
| 214 | }; | ||
| 215 | let opened = false; | ||
| 216 | const error = yield* Effect.flip( | ||
| 217 | updateOutdatedHost(TARGET.environmentId, { targetVersion: "0.0.46" }, () => Effect.void), | ||
| 218 | ).pipe( | ||
| 219 | Effect.provide( | ||
| 220 | Layer.mergeAll( | ||
| 221 | Layer.succeed( | ||
| 222 | EnvironmentRegistry.EnvironmentRegistry, | ||
| 223 | EnvironmentRegistry.EnvironmentRegistry.of({ | ||
| 224 | entries: yield* SubscriptionRef.make< | ||
| 225 | ReadonlyMap<EnvironmentId, ConnectionCatalogEntry> | ||
| 226 | >( | ||
| 227 | new Map([ | ||
| 228 | [ | ||
| 229 | TARGET.environmentId, | ||
| 230 | { target: TARGET, profile: Option.none(), enabled: false }, | ||
| 231 | ], | ||
| 232 | ]), | ||
| 233 | ), | ||
| 234 | } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), | ||
| 235 | ), | ||
| 236 | Layer.succeed( | ||
| 237 | ConnectionResolver.ConnectionResolver, | ||
| 238 | ConnectionResolver.ConnectionResolver.of({ | ||
| 239 | prepare: () => Effect.die(new Error("unused")), | ||
| 240 | prepareForUpdate: () => | ||
| 241 | Effect.succeed({ | ||
| 242 | descriptor: manual, | ||
| 243 | prepared: { | ||
| 244 | environmentId: TARGET.environmentId, | ||
| 245 | label: TARGET.label, | ||
| 246 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 247 | socketUrl: "wss://build.example.test/ws", | ||
| 248 | httpAuthorization: null, | ||
| 249 | target: TARGET, | ||
| 250 | }, | ||
| 251 | }), | ||
| 252 | }), | ||
| 253 | ), | ||
| 254 | Layer.succeed( | ||
| 255 | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, | ||
| 256 | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ | ||
| 257 | state: yield* SubscriptionRef.make( | ||
| 258 | RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, | ||
| 259 | ), | ||
| 260 | refresh: Effect.void, | ||
| 261 | }), | ||
| 262 | ), | ||
| 263 | Layer.succeed( | ||
| 264 | HttpClient.HttpClient, | ||
| 265 | HttpClient.make(() => Effect.die(new Error("unused"))), | ||
| 266 | ), | ||
| 267 | Layer.succeed(Socket.WebSocketConstructor, () => { | ||
| 268 | opened = true; | ||
| 269 | throw new Error("unused"); | ||
| 270 | }), | ||
| 271 | ), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | expect(error).toMatchObject({ _tag: "OutdatedHostUpdateError" }); | ||
| 275 | expect(opened).toBe(false); | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | }); | ||
packages/client-runtime/src/connection/outdatedHostUpdate.ts created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | import { | ||
| 2 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 3 | type EnvironmentId, | ||
| 4 | type ExecutionEnvironmentDescriptor, | ||
| 5 | type ServerSelfUpdateInput, | ||
| 6 | type ServerSelfUpdateResult, | ||
| 7 | WS_METHODS, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Schedule from "effect/Schedule"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 19 | import * as RpcClient from "effect/unstable/rpc/RpcClient"; | ||
| 20 | import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; | ||
| 21 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 22 | |||
| 23 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 24 | import { makeWsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 25 | import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "../state/server.ts"; | ||
| 26 | import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; | ||
| 27 | import * as ConnectionResolver from "./resolver.ts"; | ||
| 28 | import * as EnvironmentRegistry from "./registry.ts"; | ||
| 29 | |||
| 30 | // A v1 host restarting into v2 runs migrations before its descriptor answers again. | ||
| 31 | const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4); | ||
| 32 | const SOCKET_OPEN_TIMEOUT = "15 seconds"; | ||
| 33 | |||
| 34 | export class OutdatedHostUpdateError extends Schema.TaggedError<OutdatedHostUpdateError>()( | ||
| 35 | "OutdatedHostUpdateError", | ||
| 36 | { | ||
| 37 | environmentId: Schema.String, | ||
| 38 | message: Schema.String, | ||
| 39 | }, | ||
| 40 | ) {} | ||
| 41 | |||
| 42 | export type OutdatedHostUpdateStage = "downloading" | "installing" | "resuming"; | ||
| 43 | |||
| 44 | /** | ||
| 45 | * Updates a host whose orchestration protocol is too old for this client. | ||
| 46 | * | ||
| 47 | * The normal session refuses to open against such a host, so this opens a | ||
| 48 | * bare socket and calls only the self-update RPCs, whose wire shape has not | ||
| 49 | * changed across protocol versions. Once the host relaunches on a compatible | ||
| 50 | * protocol the environment is switched back on and connects normally. | ||
| 51 | */ | ||
| 52 | export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutdatedHost")( | ||
| 53 | function* ( | ||
| 54 | environmentId: EnvironmentId, | ||
| 55 | input: ServerSelfUpdateInput, | ||
| 56 | onStage: (stage: OutdatedHostUpdateStage) => Effect.Effect<void>, | ||
| 57 | ) { | ||
| 58 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | ||
| 59 | const resolver = yield* ConnectionResolver.ConnectionResolver; | ||
| 60 | const webSocketConstructor = yield* Socket.WebSocketConstructor; | ||
| 61 | const httpClient = yield* HttpClient.HttpClient; | ||
| 62 | const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); | ||
| 63 | if (entry === undefined) { | ||
| 64 | return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId }); | ||
| 65 | } | ||
| 66 | const { prepared, descriptor } = yield* resolver.prepareForUpdate(entry); | ||
| 67 | const capabilities = descriptor.capabilities; | ||
| 68 | if ( | ||
| 69 | capabilities.serverSelfUpdate === undefined || | ||
| 70 | (capabilities.serverSelfUpdate === "desktop-managed" && | ||
| 71 | capabilities.desktopAppUpdate !== true) | ||
| 72 | ) { | ||
| 73 | return yield* new OutdatedHostUpdateError({ | ||
| 74 | environmentId, | ||
| 75 | message: `Update T3 Code on ${descriptor.label} manually; it cannot update itself.`, | ||
| 76 | }); | ||
| 77 | } | ||
| 78 | |||
| 79 | const result = yield* Effect.scoped( | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const protocolContext = yield* Layer.build( | ||
| 82 | Layer.effect( | ||
| 83 | RpcClient.Protocol, | ||
| 84 | RpcClient.makeProtocolSocket({ | ||
| 85 | retryTransientErrors: false, | ||
| 86 | retryPolicy: Schedule.recurs(0), | ||
| 87 | }), | ||
| 88 | ).pipe( | ||
| 89 | Layer.provide( | ||
| 90 | Layer.mergeAll( | ||
| 91 | Socket.layerWebSocket(prepared.socketUrl, { | ||
| 92 | openTimeout: SOCKET_OPEN_TIMEOUT, | ||
| 93 | }).pipe( | ||
| 94 | Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)), | ||
| 95 | ), | ||
| 96 | RpcSerialization.layerJson, | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); | ||
| 102 | |||
| 103 | const updateResult: ServerSelfUpdateResult = | ||
| 104 | capabilities.serverSelfUpdateProgress === true | ||
| 105 | ? yield* Effect.gen(function* () { | ||
| 106 | const terminal = yield* Ref.make(Option.none<ServerSelfUpdateResult>()); | ||
| 107 | const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress]( | ||
| 108 | input, | ||
| 109 | ).pipe( | ||
| 110 | Stream.runForEach((event) => | ||
| 111 | event.type === "complete" | ||
| 112 | ? Ref.set(terminal, Option.some(event.result)) | ||
| 113 | : onStage(event.stage), | ||
| 114 | ), | ||
| 115 | Effect.exit, | ||
| 116 | ); | ||
| 117 | return yield* resolveServerUpdateProgressResult( | ||
| 118 | input.targetVersion, | ||
| 119 | yield* Ref.get(terminal), | ||
| 120 | streamExit, | ||
| 121 | ); | ||
| 122 | }) | ||
| 123 | : yield* client[WS_METHODS.serverUpdateServer](input).pipe( | ||
| 124 | // Older servers can drop the socket before acknowledging the restart. | ||
| 125 | Effect.catchCauseIf( | ||
| 126 | (cause) => | ||
| 127 | (capabilities.serverSelfUpdate === "boot-service" || | ||
| 128 | capabilities.serverSelfUpdate === "respawn") && | ||
| 129 | isLegacyUpdateHandoffLoss(cause), | ||
| 130 | () => | ||
| 131 | Effect.succeed({ | ||
| 132 | targetVersion: input.targetVersion, | ||
| 133 | method: capabilities.serverSelfUpdate as "boot-service" | "respawn", | ||
| 134 | } satisfies ServerSelfUpdateResult), | ||
| 135 | ), | ||
| 136 | ); | ||
| 137 | |||
| 138 | if ( | ||
| 139 | updateResult.method === "desktop-app" && | ||
| 140 | updateResult.desktopUpdateToken !== undefined | ||
| 141 | ) { | ||
| 142 | // The commit relaunches the desktop app, so a dropped socket is success. | ||
| 143 | yield* client[WS_METHODS.serverCommitDesktopUpdate]({ | ||
| 144 | requestId: updateResult.desktopUpdateToken, | ||
| 145 | }).pipe( | ||
| 146 | Effect.catchCauseIf( | ||
| 147 | (cause) => isLegacyUpdateHandoffLoss(cause), | ||
| 148 | () => Effect.void, | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | return updateResult; | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | |||
| 156 | yield* onStage("resuming"); | ||
| 157 | const resumed = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 158 | httpBaseUrl: prepared.httpBaseUrl, | ||
| 159 | }).pipe( | ||
| 160 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 161 | Effect.option, | ||
| 162 | Effect.repeat({ | ||
| 163 | schedule: Schedule.spaced(Duration.seconds(1)), | ||
| 164 | until: (current) => Option.exists(current, isCompatibleDescriptor), | ||
| 165 | }), | ||
| 166 | Effect.timeoutOption(OUTDATED_HOST_RESTART_TIMEOUT), | ||
| 167 | Effect.map(Option.flatten), | ||
| 168 | ); | ||
| 169 | if (Option.isNone(resumed)) { | ||
| 170 | return yield* new OutdatedHostUpdateError({ | ||
| 171 | environmentId, | ||
| 172 | message: `${descriptor.label} did not come back on a compatible T3 Code version.`, | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | |||
| 176 | // Discovery still holds the old relay descriptor and would re-block the | ||
| 177 | // environment from it, so replace that before clearing the block. | ||
| 178 | if (entry.target._tag === "RelayConnectionTarget") { | ||
| 179 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | ||
| 180 | yield* discovery.refresh; | ||
| 181 | } | ||
| 182 | yield* registry.setCompatibility(environmentId, null); | ||
| 183 | yield* registry.setEnabled(environmentId, true); | ||
| 184 | return { ...result, targetVersion: resumed.value.serverVersion }; | ||
| 185 | }, | ||
| 186 | ); | ||
| 187 | |||
| 188 | function isCompatibleDescriptor(descriptor: ExecutionEnvironmentDescriptor): boolean { | ||
| 189 | return (descriptor.orchestrationProtocolVersion ?? 1) === ORCHESTRATION_PROTOCOL_VERSION; | ||
| 190 | } | ||
packages/client-runtime/src/connection/registry.ts+31-5| ... | @@ -42,6 +42,17 @@ import { | ... | @@ -42,6 +42,17 @@ import { |
| 42 | 42 | ||
| 43 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); | 43 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); |
| 44 | 44 | ||
| 45 | function unsupportedState( | ||
| 46 | entry: ConnectionCatalogEntry, | ||
| 47 | ): Pick<ConnectionCatalogEntry, "unsupportedReason" | "serverUpdateRequired"> { | ||
| 48 | return { | ||
| 49 | ...(entry.unsupportedReason === undefined | ||
| 50 | ? {} | ||
| 51 | : { unsupportedReason: entry.unsupportedReason }), | ||
| 52 | ...(entry.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 45 | export class EnvironmentNotRegisteredError extends Schema.TaggedError<EnvironmentNotRegisteredError>()( | 56 | export class EnvironmentNotRegisteredError extends Schema.TaggedError<EnvironmentNotRegisteredError>()( |
| 46 | "EnvironmentNotRegisteredError", | 57 | "EnvironmentNotRegisteredError", |
| 47 | { | 58 | { |
| ... | @@ -455,7 +466,7 @@ export const make = Effect.gen(function* () { | ... | @@ -455,7 +466,7 @@ export const make = Effect.gen(function* () { |
| 455 | enabled: previous.enabled, | 466 | enabled: previous.enabled, |
| 456 | ...(previous.unsupportedReason !== undefined && | 467 | ...(previous.unsupportedReason !== undefined && |
| 457 | gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) | 468 | gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) |
| 458 | ? { unsupportedReason: previous.unsupportedReason } | 469 | ? unsupportedState(previous) |
| 459 | : {}), | 470 | : {}), |
| 460 | }; | 471 | }; |
| 461 | if ( | 472 | if ( |
| ... | @@ -494,7 +505,7 @@ export const make = Effect.gen(function* () { | ... | @@ -494,7 +505,7 @@ export const make = Effect.gen(function* () { |
| 494 | const entry: ConnectionCatalogEntry = | 505 | const entry: ConnectionCatalogEntry = |
| 495 | previous?.unsupportedReason !== undefined && | 506 | previous?.unsupportedReason !== undefined && |
| 496 | gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) | 507 | gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) |
| 497 | ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason } | 508 | ? { ...registered, enabled: false, ...unsupportedState(previous) } |
| 498 | : registered; | 509 | : registered; |
| 499 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( | 510 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( |
| 500 | target.environmentId, | 511 | target.environmentId, |
| ... | @@ -844,11 +855,26 @@ export const make = Effect.gen(function* () { | ... | @@ -844,11 +855,26 @@ export const make = Effect.gen(function* () { |
| 844 | environmentId, | 855 | environmentId, |
| 845 | Effect.gen(function* () { | 856 | Effect.gen(function* () { |
| 846 | const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); | 857 | const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); |
| 847 | if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined)) | 858 | if ( |
| 859 | entry === undefined || | ||
| 860 | (entry.unsupportedReason === (error?.message ?? undefined) && | ||
| 861 | entry.serverUpdateRequired === (error?.serverUpdateRequired ?? undefined)) | ||
| 862 | ) | ||
| 848 | return; | 863 | return; |
| 849 | const { unsupportedReason: _previousReason, ...rest } = entry; | 864 | const { |
| 865 | unsupportedReason: _previousReason, | ||
| 866 | serverUpdateRequired: _previousUpdateRequired, | ||
| 867 | ...rest | ||
| 868 | } = entry; | ||
| 850 | const next: ConnectionCatalogEntry = | 869 | const next: ConnectionCatalogEntry = |
| 851 | error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message }; | 870 | error === null |
| 871 | ? rest | ||
| 872 | : { | ||
| 873 | ...rest, | ||
| 874 | enabled: false, | ||
| 875 | unsupportedReason: error.message, | ||
| 876 | ...(error.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), | ||
| 877 | }; | ||
| 852 | if ( | 878 | if ( |
| 853 | error !== null && | 879 | error !== null && |
| 854 | entry.enabled && | 880 | entry.enabled && |
packages/client-runtime/src/connection/resolver.ts+24-3| ... | @@ -1,4 +1,7 @@ | ... | @@ -1,4 +1,7 @@ |
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | 1 | import type { |
| 2 | AuthClientPresentationMetadata, | ||
| 3 | ExecutionEnvironmentDescriptor, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 5 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 3 | import * as Context from "effect/Context"; | 6 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| ... | @@ -49,6 +52,17 @@ export class ConnectionResolver extends Context.Service< | ... | @@ -49,6 +52,17 @@ export class ConnectionResolver extends Context.Service< |
| 49 | readonly prepare: ( | 52 | readonly prepare: ( |
| 50 | entry: ConnectionCatalogEntry, | 53 | entry: ConnectionCatalogEntry, |
| 51 | ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>; | 54 | ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>; |
| 55 | /** | ||
| 56 | * Authorizes a socket without the orchestration protocol gate, for hosts | ||
| 57 | * too old to connect normally. Only update RPCs may run over it. | ||
| 58 | */ | ||
| 59 | readonly prepareForUpdate: (entry: ConnectionCatalogEntry) => Effect.Effect< | ||
| 60 | { | ||
| 61 | readonly prepared: PreparedConnection; | ||
| 62 | readonly descriptor: ExecutionEnvironmentDescriptor; | ||
| 63 | }, | ||
| 64 | ConnectionAttemptError | ||
| 65 | >; | ||
| 52 | } | 66 | } |
| 53 | >()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} | 67 | >()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} |
| 54 | 68 | ||
| ... | @@ -245,7 +259,7 @@ export const make = Effect.gen(function* () { | ... | @@ -245,7 +259,7 @@ export const make = Effect.gen(function* () { |
| 245 | const ssh = yield* makeSshBroker(); | 259 | const ssh = yield* makeSshBroker(); |
| 246 | const httpClient = yield* HttpClient.HttpClient; | 260 | const httpClient = yield* HttpClient.HttpClient; |
| 247 | 261 | ||
| 248 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( | 262 | const authorize = Effect.fn("clientRuntime.connection.broker.authorize")(function* ( |
| 249 | entry: ConnectionCatalogEntry, | 263 | entry: ConnectionCatalogEntry, |
| 250 | ) { | 264 | ) { |
| 251 | const target: ConnectionTarget = entry.target; | 265 | const target: ConnectionTarget = entry.target; |
| ... | @@ -277,6 +291,13 @@ export const make = Effect.gen(function* () { | ... | @@ -277,6 +291,13 @@ export const make = Effect.gen(function* () { |
| 277 | actual: descriptor.environmentId, | 291 | actual: descriptor.environmentId, |
| 278 | }); | 292 | }); |
| 279 | } | 293 | } |
| 294 | return { prepared, descriptor }; | ||
| 295 | }); | ||
| 296 | |||
| 297 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( | ||
| 298 | entry: ConnectionCatalogEntry, | ||
| 299 | ) { | ||
| 300 | const { prepared, descriptor } = yield* authorize(entry); | ||
| 280 | const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); | 301 | const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); |
| 281 | if (compatibilityError !== null) { | 302 | if (compatibilityError !== null) { |
| 282 | return yield* compatibilityError; | 303 | return yield* compatibilityError; |
| ... | @@ -287,7 +308,7 @@ export const make = Effect.gen(function* () { | ... | @@ -287,7 +308,7 @@ export const make = Effect.gen(function* () { |
| 287 | }; | 308 | }; |
| 288 | }); | 309 | }); |
| 289 | 310 | ||
| 290 | return ConnectionResolver.of({ prepare }); | 311 | return ConnectionResolver.of({ prepare, prepareForUpdate: authorize }); |
| 291 | }); | 312 | }); |
| 292 | 313 | ||
| 293 | export const layer = Layer.effect(ConnectionResolver, make); | 314 | export const layer = Layer.effect(ConnectionResolver, make); |
packages/client-runtime/src/state/outdatedServerUpdate.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import type { EnvironmentId, ServerSelfUpdateInput } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import type * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 6 | import type { Atom } from "effect/unstable/reactivity"; | ||
| 7 | import type * as Socket from "effect/unstable/socket/Socket"; | ||
| 8 | |||
| 9 | import { updateOutdatedHost } from "../connection/outdatedHostUpdate.ts"; | ||
| 10 | import type * as ConnectionResolver from "../connection/resolver.ts"; | ||
| 11 | import type * as EnvironmentRegistry from "../connection/registry.ts"; | ||
| 12 | import type * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; | ||
| 13 | import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts"; | ||
| 14 | import { | ||
| 15 | serverUpdateFailureMessage, | ||
| 16 | serverUpdateStateAtom, | ||
| 17 | type ServerUpdateStage, | ||
| 18 | } from "./server.ts"; | ||
| 19 | |||
| 20 | export interface OutdatedServerUpdateTarget { | ||
| 21 | readonly environmentId: EnvironmentId; | ||
| 22 | readonly input: ServerSelfUpdateInput; | ||
| 23 | /** From the host descriptor when known; an outdated host never delivers a server config. */ | ||
| 24 | readonly fromVersion?: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Updates a host too old for this client to connect to. Progress lands in the | ||
| 29 | * same per-environment update state as a normal server update. | ||
| 30 | */ | ||
| 31 | export function createOutdatedServerUpdateCommand<E>( | ||
| 32 | runtime: Atom.AtomRuntime< | ||
| 33 | | EnvironmentRegistry.EnvironmentRegistry | ||
| 34 | | ConnectionResolver.ConnectionResolver | ||
| 35 | | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery | ||
| 36 | | Socket.WebSocketConstructor | ||
| 37 | | HttpClient.HttpClient, | ||
| 38 | E | ||
| 39 | >, | ||
| 40 | ) { | ||
| 41 | return createRuntimeCommand(runtime, { | ||
| 42 | label: "environment-data:server:update-outdated-server", | ||
| 43 | scheduler: createAtomCommandScheduler(), | ||
| 44 | concurrency: { | ||
| 45 | mode: "singleFlight", | ||
| 46 | key: ({ environmentId }: OutdatedServerUpdateTarget) => environmentId, | ||
| 47 | }, | ||
| 48 | execute: (target: OutdatedServerUpdateTarget, atomRegistry) => { | ||
| 49 | const stateAtom = serverUpdateStateAtom(target.environmentId); | ||
| 50 | const targetVersion = target.input.targetVersion; | ||
| 51 | const fromVersion = target.fromVersion ?? targetVersion; | ||
| 52 | let currentStage: ServerUpdateStage = "downloading"; | ||
| 53 | const setStage = (stage: ServerUpdateStage) => | ||
| 54 | Effect.sync(() => { | ||
| 55 | currentStage = stage; | ||
| 56 | atomRegistry.set(stateAtom, { status: "running", stage, fromVersion, targetVersion }); | ||
| 57 | }); | ||
| 58 | return setStage(currentStage).pipe( | ||
| 59 | Effect.andThen(updateOutdatedHost(target.environmentId, target.input, setStage)), | ||
| 60 | Effect.onExit((exit) => | ||
| 61 | Effect.sync(() => { | ||
| 62 | if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { | ||
| 63 | atomRegistry.set(stateAtom, { status: "idle" }); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | atomRegistry.set(stateAtom, { | ||
| 67 | status: "failed", | ||
| 68 | stage: currentStage, | ||
| 69 | fromVersion, | ||
| 70 | targetVersion, | ||
| 71 | message: serverUpdateFailureMessage(Cause.squash(exit.cause)), | ||
| 72 | }); | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | }, | ||
| 77 | }); | ||
| 78 | } | ||
packages/client-runtime/src/state/server.ts+3-2| ... | @@ -84,7 +84,8 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; | ... | @@ -84,7 +84,8 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; |
| 84 | const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( | 84 | const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( |
| 85 | Atom.withLabel("environment-data:server:update-state:empty"), | 85 | Atom.withLabel("environment-data:server:update-state:empty"), |
| 86 | ); | 86 | ); |
| 87 | const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => | 87 | /** Shared with the outdated-host update, which reports through the same state. */ |
| 88 | export const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 88 | Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( | 89 | Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( |
| 89 | Atom.withLabel(`environment-data:server:update-state:${environmentId}`), | 90 | Atom.withLabel(`environment-data:server:update-state:${environmentId}`), |
| 90 | ), | 91 | ), |
| ... | @@ -307,7 +308,7 @@ export function serverUpdateStateForServerVersion( | ... | @@ -307,7 +308,7 @@ export function serverUpdateStateForServerVersion( |
| 307 | : IDLE_SERVER_UPDATE_STATE; | 308 | : IDLE_SERVER_UPDATE_STATE; |
| 308 | } | 309 | } |
| 309 | 310 | ||
| 310 | function serverUpdateFailureMessage(error: unknown): string { | 311 | export function serverUpdateFailureMessage(error: unknown): string { |
| 311 | return error instanceof Error ? error.message : "Server update failed."; | 312 | return error instanceof Error ? error.message : "Server update failed."; |
| 312 | } | 313 | } |
| 313 | 314 |