Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { type ComponentProps, useRef, useState } from "react";
import { requestConfirmDialog } from "~/confirmDialog";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useEnvironmentSettings } from "~/hooks/useSettings";
import { serverEnvironment } from "~/state/server";
import { serverEnvironment, updateOutdatedServer } from "~/state/server";
import { useAtomCommand } from "~/state/use-atom-command";
import { manualServerUpdateCommand } from "~/versionSkew";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -290,3 +290,56 @@ export function ServerUpdateAction({
</Button>
);
}

/**
* Updates a host too old for this client to connect to. Its version comes
* from the host descriptor because the host never delivers a server config.
*/
export function OutdatedServerUpdateAction({
environmentId,
serverLabel,
fromVersion,
targetVersion,
label = "Update",
}: {
readonly environmentId: EnvironmentId;
readonly serverLabel: string;
readonly fromVersion: string | undefined;
readonly targetVersion: string;
readonly label?: string;
}) {
const update = useAtomCommand(updateOutdatedServer, { reportFailure: false });
const handleUpdate = async () => {
if (pendingUpdateEnvironmentIds.has(environmentId)) return;
pendingUpdateEnvironmentIds.add(environmentId);
try {
const result = await update({
environmentId,
input: { targetVersion },
...(fromVersion === undefined ? {} : { fromVersion }),
});
if (result._tag === "Failure") {
if (isAtomCommandInterrupted(result)) return;
throw squashAtomCommandFailure(result);
}
toastManager.add({
type: "success",
title: `${serverLabel} updated`,
description: `Reconnected on t3@${result.value.targetVersion}.`,
});
} catch (error) {
toastManager.add({
type: "error",
title: "Server update failed",
description: updateFailureMessage(error),
});
} finally {
pendingUpdateEnvironmentIds.delete(environmentId);
}
};
return (
<Button size="xs" variant="outline" onClick={() => void handleUpdate()}>
{label}
</Button>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,13 @@ import {
usePrimaryEnvironment,
useRelayEnvironmentDiscovery,
} from "~/state/environments";
import { APP_VERSION } from "~/branding";
import { requestConfirmDialog } from "~/confirmDialog";
import { useAtomCommand } from "../../state/use-atom-command";
import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
import { ConnectionStatusDot } from "../ConnectionStatusDot";
import {
OutdatedServerUpdateAction,
ServerUpdateAction,
ServerUpdateProgress,
ServerUpdatesAction,
Expand Down Expand Up @@ -1598,6 +1600,17 @@ function SavedBackendListRow({
) : null
}
>
{unsupported &&
environment.entry.serverUpdateRequired === true &&
serverUpdateState.status !== "running" ? (
<OutdatedServerUpdateAction
environmentId={environmentId}
serverLabel={`${environment.label} server`}
fromVersion={lastDescriptor?.serverVersion}
targetVersion={APP_VERSION}
label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
/>
) : null}
{showUpdateAction ? (
<ServerUpdateAction
environmentId={environmentId}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ServerSettings,
} from "@t3tools/contracts";
import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate";
import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
import * as Option from "effect/Option";
Expand All @@ -32,6 +33,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
usageLimitSources: true,
usageLimitsCommand: true,
});
/** Updates a host whose protocol is too old for this client to connect to. */
export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime);
export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
serverConfigValueAtom: serverEnvironment.configValueAtom,
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@
"types": "./src/state/server.ts",
"default": "./src/state/server.ts"
},
"./state/outdatedServerUpdate": {
"types": "./src/state/outdatedServerUpdate.ts",
"default": "./src/state/outdatedServerUpdate.ts"
},
"./state/session": {
"types": "./src/state/session.ts",
"default": "./src/state/session.ts"
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/connection/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ConnectionCatalogEntry {
readonly enabled: boolean;
/** Discovery rejection stays visible while the saved connection is switched off. */
readonly unsupportedReason?: string;
/** The rejection came from an outdated host, which can still be updated remotely. */
readonly serverUpdateRequired?: boolean;
}

export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
Expand Down
24 changes: 24 additions & 0 deletions packages/client-runtime/src/connection/compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => {
);
expect(error).toMatchObject({ reason: "unsupported" });
expect(error?.message).toContain("This client is not supported");
expect(error).not.toHaveProperty("serverUpdateRequired");
});

it("offers a remote update only for an older host that can update itself", () => {
const older = descriptor(ORCHESTRATION_PROTOCOL_VERSION - 1);
const withCapabilities = (capabilities: ExecutionEnvironmentDescriptor["capabilities"]) =>
orchestrationProtocolCompatibilityError({ ...older, capabilities });

expect(
withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "boot-service" }),
).toMatchObject({ serverUpdateRequired: true });
expect(withCapabilities({ repositoryIdentity: true })).not.toHaveProperty(
"serverUpdateRequired",
);
expect(
withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }),
).not.toHaveProperty("serverUpdateRequired");
expect(
withCapabilities({
repositoryIdentity: true,
serverSelfUpdate: "desktop-managed",
desktopAppUpdate: true,
}),
).toMatchObject({ serverUpdateRequired: true });
});
});
26 changes: 19 additions & 7 deletions packages/client-runtime/src/connection/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError(
if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) {
return null;
}
return new ConnectionBlockedError({
reason: "unsupported",
detail:
serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION
? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`
: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`,
});
return serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION
? new ConnectionBlockedError({
reason: "unsupported",
detail: `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`,
})
: new ConnectionBlockedError({
reason: "unsupported",
detail: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`,
...(canSelfUpdate(descriptor) ? { serverUpdateRequired: true } : {}),
});
}

/** Whether this client can drive the host's update remotely. */
function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean {
const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities;
return (
serverSelfUpdate !== undefined &&
(serverSelfUpdate !== "desktop-managed" || desktopAppUpdate === true)
);
}

export function appendOrchestrationProtocol(socketUrl: string): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/connection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts";
export * as Wakeups from "./wakeups.ts";

export { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
// Flat so consumers' inferred command types can name it.
export { OutdatedHostUpdateError } from "./outdatedHostUpdate.ts";
2 changes: 2 additions & 0 deletions packages/client-runtime/src/connection/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
registryLayer,
RelayEnvironmentDiscovery.layer,
onboardingLayer,
// Exposed for updating hosts too old to connect through the driver.
ConnectionResolver.layer,
);
const connectionStartupLayer = Layer.effectDiscard(
Effect.gen(function* () {
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/connection/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError<ConnectionBlocked
reason: ConnectionBlockedReason,
detail: Schema.String,
traceId: Schema.optionalKey(Schema.String),
/** The host speaks an older orchestration protocol; updating it restores the connection. */
serverUpdateRequired: Schema.optionalKey(Schema.Boolean),
},
) {
override get message(): string {
Expand Down
52 changes: 51 additions & 1 deletion packages/client-runtime/src/connection/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed(

function pairingHttpLayer(
calls: Array<{ readonly url: string; readonly init: RequestInit }>,
options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number },
options?: {
readonly failDescriptor?: boolean;
readonly protocolVersion?: number;
readonly selfUpdate?: boolean;
},
) {
const fetchFn = ((input, init = {}) => {
const url = String(input);
Expand All @@ -56,6 +60,7 @@ function pairingHttpLayer(
orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION,
capabilities: {
repositoryIdentity: true,
...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}),
},
}),
);
Expand Down Expand Up @@ -145,6 +150,51 @@ describe("connection onboarding", () => {
}),
);

it.effect("pairs an outdated server so it can be updated from this client", () =>
Effect.gen(function* () {
const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
const registration = yield* preparePairingRegistration({
host: "remote.example.test",
pairingCode: "pairing-token",
}).pipe(
Effect.provide(
Layer.mergeAll(
CLIENT_PRESENTATION_LAYER,
pairingHttpLayer(calls, {
protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1,
selfUpdate: true,
}),
),
),
);
expect(registration.target.environmentId).toBe("environment-paired");
expect(calls.map((call) => call.url)).toContain("https://remote.example.test/oauth/token");
}),
);

it.effect("refuses an outdated server that cannot update itself", () =>
Effect.gen(function* () {
const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
const error = yield* preparePairingRegistration({
host: "remote.example.test",
pairingCode: "pairing-token",
}).pipe(
Effect.provide(
Layer.mergeAll(
CLIENT_PRESENTATION_LAYER,
pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }),
),
),
Effect.flip,
);
expect(error).toMatchObject({ reason: "unsupported" });
expect(error).not.toHaveProperty("serverUpdateRequired");
expect(calls.map((call) => call.url)).toEqual([
"https://remote.example.test/.well-known/t3/environment",
]);
}),
);

it.effect("does not consume a pairing credential when descriptor discovery fails", () =>
Effect.gen(function* () {
const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
Expand Down
5 changes: 4 additions & 1 deletion packages/client-runtime/src/connection/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn(
httpBaseUrl: target.httpBaseUrl,
}).pipe(Effect.mapError(mapRemoteEnvironmentError));
const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
if (compatibilityError !== null) return yield* compatibilityError;
// An outdated server is still saved so it can be updated from this client.
if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {
return yield* compatibilityError;
}
const access = yield* bootstrapRemoteBearerSession({
httpBaseUrl: target.httpBaseUrl,
credential: target.credential,
Expand Down
Loading
Loading