authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:45:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:45:04-07:00
log197d3487116372ca2f16661f57d4e869fc3cb6c0
treeeeb4130265705a341ac320f2b416df7bb2c42118
parent5c9358acaa34f8619513bba1e6821f0fdec27f74
signature Signed by PGP key B5690EEEBB952194

fix(web): show server update progress through reconnect (#4903)


21 files changed, 1072 insertions(+), 364 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
...@@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = {
32 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,32 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
33 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,33 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
34 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,34 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,36 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
36 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,37 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
37 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,38 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
apps/server/src/cloud/selfUpdate.test.ts+40-5
...@@ -26,6 +26,33 @@ import * as SelfUpdate from "./selfUpdate.ts";...@@ -26,6 +26,33 @@ import * as SelfUpdate from "./selfUpdate.ts";
2626
27const NODE_PATH = "/usr/local/bin/node";27const NODE_PATH = "/usr/local/bin/node";
2828
29const eventuallyFileString = Effect.fn("test.eventuallyFileString")(function* (
30 filePath: string,
31 expected: string,
32) {
33 const fs = yield* FileSystem.FileSystem;
34 for (let iteration = 0; iteration < 1_000; iteration += 1) {
35 const contents = yield* fs.readFileString(filePath);
36 if (contents === expected) {
37 return;
38 }
39 // The rollback performs real filesystem I/O on a detached fiber, which
40 // advancing TestClock does not await.
41 yield* Effect.yieldNow;
42 }
43 return yield* Effect.die(new Error(`Expected file contents were not observed at ${filePath}.`));
44});
45
46const eventuallyTrue = Effect.fn("test.eventuallyTrue")(function* (predicate: () => boolean) {
47 for (let iteration = 0; iteration < 1_000; iteration += 1) {
48 if (predicate()) {
49 return;
50 }
51 yield* Effect.yieldNow;
52 }
53 return yield* Effect.die(new Error("Expected condition was not observed."));
54});
55
29interface RecordedCommand {56interface RecordedCommand {
30 readonly command: string;57 readonly command: string;
31 readonly args: ReadonlyArray<string>;58 readonly args: ReadonlyArray<string>;
...@@ -457,8 +484,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {...@@ -457,8 +484,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
457 it.effect("installs, preflights, and respawns a foreground server", () =>484 it.effect("installs, preflights, and respawns a foreground server", () =>
458 Effect.gen(function* () {485 Effect.gen(function* () {
459 const context = yield* makeContext();486 const context = yield* makeContext();
460 const result = yield* context.service.update({ targetVersion: "0.0.29" });487 const progress: Array<string> = [];
488 const result = yield* context.service.update({ targetVersion: "0.0.29" }, (stage) =>
489 Effect.sync(() => progress.push(stage)),
490 );
461 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });491 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
492 assert.deepEqual(progress, ["downloading", "installing"]);
462 assert.lengthOf(context.spawns, 1);493 assert.lengthOf(context.spawns, 1);
463494
464 const concurrentError = yield* context.service495 const concurrentError = yield* context.service
...@@ -506,10 +537,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {...@@ -506,10 +537,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
506 assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);537 assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);
507 assert.deepEqual(538 assert.deepEqual(
508 context.commands.map((entry) => entry.command),539 context.commands.map((entry) => entry.command),
509 ["npm", NODE_PATH, "systemctl", "systemctl"],540 ["npm", NODE_PATH, "systemctl"],
510 );541 );
511 assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]);542 assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]);
512543
544 // Restart waits until after the update acknowledgement can flush.
545 yield* TestClock.adjust(Duration.seconds(10));
513 assert.deepEqual(context.commands[3], {546 assert.deepEqual(context.commands[3], {
514 command: "systemctl",547 command: "systemctl",
515 args: ["--user", "restart", "t3code.service"],548 args: ["--user", "restart", "t3code.service"],
...@@ -542,9 +575,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {...@@ -542,9 +575,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
542 );575 );
543 const previousUnit = yield* context.fs.readFileString(unitPath);576 const previousUnit = yield* context.fs.readFileString(unitPath);
544577
545 const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);578 const first = yield* context.service.update({ targetVersion: "0.0.29" });
546 assert.include(first.reason, "Restarting the systemd boot service failed");579 assert.deepEqual(first, { targetVersion: "0.0.29", method: "boot-service" });
547 assert.equal(yield* context.fs.readFileString(unitPath), previousUnit);580 yield* TestClock.adjust(Duration.seconds(10));
581 yield* eventuallyFileString(unitPath, previousUnit);
582 yield* eventuallyTrue(() => context.commands.at(-1)?.args[1] === "daemon-reload");
548 assert.deepEqual(583 assert.deepEqual(
549 context.commands.slice(-2).map((entry) => entry.args),584 context.commands.slice(-2).map((entry) => entry.args),
550 [585 [
apps/server/src/cloud/selfUpdate.ts+41-30
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ServerSelfUpdateError,6 ServerSelfUpdateError,
7 type ServerSelfUpdateCapability,7 type ServerSelfUpdateCapability,
8 type ServerSelfUpdateInput,8 type ServerSelfUpdateInput,
9 type ServerSelfUpdateProgressStage,
9 type ServerSelfUpdateResult,10 type ServerSelfUpdateResult,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import {12import {
...@@ -147,6 +148,7 @@ export class ServerSelfUpdate extends Context.Service<...@@ -147,6 +148,7 @@ export class ServerSelfUpdate extends Context.Service<
147 {148 {
148 readonly update: (149 readonly update: (
149 input: ServerSelfUpdateInput,150 input: ServerSelfUpdateInput,
151 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void, never, never>,
150 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;152 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
151 }153 }
152>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}154>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
...@@ -227,7 +229,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -227,7 +229,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
227229
228 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(230 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
229 "cloud.server_self_update.update",231 "cloud.server_self_update.update",
230 )(function* (input) {232 )(function* (input, reportProgress = () => Effect.void) {
231 if (capability === "desktop-managed") {233 if (capability === "desktop-managed") {
232 return yield* failWith(234 return yield* failWith(
233 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",235 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
...@@ -250,6 +252,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -250,6 +252,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
250 }252 }
251253
252 return yield* Effect.gen(function* () {254 return yield* Effect.gen(function* () {
255 yield* reportProgress("downloading");
253 const runtimePaths = yield* ensurePinnedRuntimeInstalled({256 const runtimePaths = yield* ensurePinnedRuntimeInstalled({
254 baseDir: serverConfig.baseDir,257 baseDir: serverConfig.baseDir,
255 version: targetVersion,258 version: targetVersion,
...@@ -260,6 +263,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -260,6 +263,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
260 Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),263 Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),
261 );264 );
262265
266 yield* reportProgress("installing");
263 // A broken artifact (failed native build, incompatible node) must be267 // A broken artifact (failed native build, incompatible node) must be
264 // caught while the current server is still alive to report it.268 // caught while the current server is still alive to report it.
265 const preflight = yield* runner269 const preflight = yield* runner
...@@ -349,38 +353,45 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -349,38 +353,45 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
349 yield* Effect.logInfo("Server self-update installed; restarting boot service.", {353 yield* Effect.logInfo("Server self-update installed; restarting boot service.", {
350 targetVersion,354 targetVersion,
351 });355 });
352 // A successful systemd restart stops this process, so the RPC is356 // Restart after the acknowledgement has had time to cross any relay
353 // interrupted and the reconnecting client observes the new version.357 // hop. If systemd rejects the handoff, restore the previous unit while
354 // A rejected restart returns while the old process is still alive;358 // this process is still alive and log the failure for diagnostics.
355 // restore the previous unit and report that failure through the RPC.359 yield* scheduleRestart(
356 yield* Effect.gen(function* () {360 Effect.gen(function* () {
357 const restart = yield* runner361 const restart = yield* runner
358 .run({362 .run({
359 command: "systemctl",363 command: "systemctl",
360 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],364 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
361 })365 })
362 .pipe(366 .pipe(
363 Effect.mapError((cause) =>367 Effect.mapError((cause) =>
364 failWith("Could not restart the systemd boot service.", cause),368 failWith("Could not restart the systemd boot service.", cause),
369 ),
370 );
371 if (restart.code !== 0) {
372 return yield* failWith(
373 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
374 );
375 }
376 }).pipe(
377 Effect.catch((restartError) =>
378 writeUnitAtomically(unitPath, previousUnit).pipe(
379 Effect.andThen(reloadSystemd()),
380 Effect.mapError((rollbackError) =>
381 failWith("Could not restore the previous systemd unit.", {
382 restartError,
383 rollbackError,
384 }),
385 ),
386 Effect.andThen(Effect.fail(restartError)),
365 ),387 ),
366 );388 ),
367 if (restart.code !== 0) {389 Effect.catch((error) =>
368 return yield* failWith(390 Effect.logError("Server self-update could not restart the boot service.").pipe(
369 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,391 Effect.annotateLogs({ targetVersion, error: error.reason }),
370 );
371 }
372 }).pipe(
373 Effect.catch((restartError) =>
374 writeUnitAtomically(unitPath, previousUnit).pipe(
375 Effect.andThen(reloadSystemd()),
376 Effect.mapError((rollbackError) =>
377 failWith("Could not restore the previous systemd unit.", {
378 restartError,
379 rollbackError,
380 }),
381 ),392 ),
382 Effect.andThen(Effect.fail(restartError)),
383 ),393 ),
394 Effect.ensuring(Ref.set(inFlight, false)),
384 ),395 ),
385 );396 );
386 } else {397 } else {
apps/server/src/environment/ServerEnvironment.ts+3
...@@ -144,6 +144,9 @@ export const make = Effect.gen(function* () {...@@ -144,6 +144,9 @@ export const make = Effect.gen(function* () {
144 threadSnooze: true,144 threadSnooze: true,
145 threadTitleRegeneration: true,145 threadTitleRegeneration: true,
146 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),146 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
147 ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn"
148 ? { serverSelfUpdateProgress: true }
149 : {}),
147 },150 },
148 };151 };
149152
apps/server/src/ws.ts+29
...@@ -40,6 +40,8 @@ import {...@@ -40,6 +40,8 @@ import {
40 ProjectWriteFileError,40 ProjectWriteFileError,
41 RelayClientInstallFailedError,41 RelayClientInstallFailedError,
42 type RelayClientInstallProgressEvent,42 type RelayClientInstallProgressEvent,
43 type ServerSelfUpdateError,
44 type ServerSelfUpdateProgressEvent,
43 type FilesystemBrowseFailure,45 type FilesystemBrowseFailure,
44 FilesystemBrowseError,46 FilesystemBrowseError,
45 AssetWorkspaceContextNotFoundError,47 AssetWorkspaceContextNotFoundError,
...@@ -1364,6 +1366,33 @@ const makeWsRpcLayer = (...@@ -1364,6 +1366,33 @@ const makeWsRpcLayer = (
1364 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {1366 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
1365 "rpc.aggregate": "server",1367 "rpc.aggregate": "server",
1366 }),1368 }),
1369 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>
1370 observeRpcStream(
1371 WS_METHODS.serverUpdateServerWithProgress,
1372 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
1373 serverSelfUpdate
1374 .update(input, (stage) =>
1375 Queue.offer(queue, {
1376 type: "progress",
1377 stage,
1378 }).pipe(Effect.asVoid),
1379 )
1380 .pipe(
1381 Effect.flatMap((result) =>
1382 Queue.offer(queue, {
1383 type: "complete",
1384 result,
1385 }),
1386 ),
1387 Effect.catchTags({
1388 ServerSelfUpdateError: (error) => Queue.fail(queue, error),
1389 }),
1390 Effect.andThen(Queue.end(queue)),
1391 Effect.forkScoped,
1392 ),
1393 ),
1394 { "rpc.aggregate": "server" },
1395 ),
1367 [WS_METHODS.serverUpsertKeybinding]: (rule) =>1396 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
1368 observeRpcEffect(1397 observeRpcEffect(
1369 WS_METHODS.serverUpsertKeybinding,1398 WS_METHODS.serverUpsertKeybinding,
apps/web/src/components/ChatView.tsx+48-25
...@@ -296,7 +296,7 @@ import {...@@ -296,7 +296,7 @@ import {
296 AlertDialogTitle,296 AlertDialogTitle,
297} from "./ui/alert-dialog";297} from "./ui/alert-dialog";
298import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";298import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
299import { ServerUpdateAction } from "./ServerUpdateAction";299import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
300import {300import {
301 buildVersionMismatchDismissalKey,301 buildVersionMismatchDismissalKey,
302 dismissVersionMismatch,302 dismissVersionMismatch,
...@@ -1876,12 +1876,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1876,12 +1876,16 @@ function ChatViewContent(props: ChatViewProps) {
1876 hasMultipleRegisteredEnvironments && activeThread1876 hasMultipleRegisteredEnvironments && activeThread
1877 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`1877 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
1878 : "server";1878 : "server";
1879 const versionMismatchEnvironmentId =1879 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
1880 versionMismatch && activeThread ? activeThread.environmentId : null;
1881 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);1880 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
1881 const serverUpdateState = useAtomValue(
1882 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
1883 );
1882 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {1884 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
1883 const items: ComposerBannerStackItem[] = [];1885 const items: ComposerBannerStackItem[] = [];
1884 if (activeEnvironmentUnavailableState) {1886 const resumingServerUpdate =
1887 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1888 if (activeEnvironmentUnavailableState && !resumingServerUpdate) {
1885 const connection = activeEnvironmentUnavailableState.connection;1889 const connection = activeEnvironmentUnavailableState.connection;
1886 const isReconnecting =1890 const isReconnecting =
1887 connection.phase === "connecting" || connection.phase === "reconnecting";1891 connection.phase === "connecting" || connection.phase === "reconnecting";
...@@ -1918,39 +1922,57 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1918,39 +1922,57 @@ function ChatViewContent(props: ChatViewProps) {
1918 });1922 });
1919 }1923 }
1920 if (1924 if (
1921 showVersionMismatchBanner &&1925 serverUpdateEnvironmentId &&
1922 versionMismatch &&1926 (serverUpdateState.status !== "idle" ||
1923 versionMismatchDismissKey &&1927 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
1924 versionMismatchEnvironmentId
1925 ) {1928 ) {
1929 const updateInProgress = serverUpdateState.status === "running";
1930 const updateFailed = serverUpdateState.status === "failed";
1926 items.push({1931 items.push({
1927 id: `version-mismatch:${versionMismatchDismissKey}`,1932 id: `server-version:${serverUpdateEnvironmentId}`,
1928 variant: "warning",1933 variant: updateFailed ? "error" : "warning",
1929 icon: <TriangleAlertIcon />,1934 icon: <TriangleAlertIcon />,
1930 title: "Client and server versions differ",1935 title:
1931 description: (1936 updateInProgress || updateFailed
1932 <>1937 ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
1933 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}1938 : "Client and server versions differ",
1934 {versionMismatch.serverVersion}.{" "}1939 description:
1935 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}1940 updateInProgress || updateFailed ? (
1936 </>1941 <ServerUpdateProgress
1937 ),1942 fromVersion={serverUpdateState.fromVersion}
1943 serverLabel={versionMismatchServerLabel}
1944 state={serverUpdateState}
1945 />
1946 ) : versionMismatch ? (
1947 <>
1948 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
1949 {versionMismatch.serverVersion}.{" "}
1950 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}
1951 </>
1952 ) : null,
1938 // The desktop-managed guidance is already the description; the action1953 // The desktop-managed guidance is already the description; the action
1939 // slot would only repeat it.1954 // slot would only repeat it.
1940 actions:1955 actions:
1956 updateInProgress ||
1957 !versionMismatch ||
1941 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (1958 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
1942 <ServerUpdateAction1959 <ServerUpdateAction
1943 environmentId={versionMismatchEnvironmentId}1960 environmentId={serverUpdateEnvironmentId}
1944 serverLabel={versionMismatchServerLabel}1961 serverLabel={versionMismatchServerLabel}
1945 selfUpdate={versionMismatchSelfUpdate}1962 selfUpdate={versionMismatchSelfUpdate}
1946 targetVersion={versionMismatch.clientVersion}1963 targetVersion={versionMismatch.clientVersion}
1964 {...(updateFailed ? { label: "Retry update" } : {})}
1947 />1965 />
1948 ),1966 ),
1949 dismissLabel: "Dismiss version mismatch warning",1967 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
1950 onDismiss: () => {1968 ? {}
1951 dismissVersionMismatch(versionMismatchDismissKey);1969 : {
1952 setDismissedVersionMismatchKey(versionMismatchDismissKey);1970 dismissLabel: "Dismiss version mismatch warning",
1953 },1971 onDismiss: () => {
1972 dismissVersionMismatch(versionMismatchDismissKey);
1973 setDismissedVersionMismatchKey(versionMismatchDismissKey);
1974 },
1975 }),
1954 });1976 });
1955 }1977 }
1956 return items;1978 return items;
...@@ -1960,9 +1982,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1960,9 +1982,10 @@ function ChatViewContent(props: ChatViewProps) {
1960 navigate,1982 navigate,
1961 setDismissedVersionMismatchKey,1983 setDismissedVersionMismatchKey,
1962 showVersionMismatchBanner,1984 showVersionMismatchBanner,
1985 serverUpdateState,
1963 versionMismatch,1986 versionMismatch,
1964 versionMismatchDismissKey,1987 versionMismatchDismissKey,
1965 versionMismatchEnvironmentId,1988 serverUpdateEnvironmentId,
1966 versionMismatchSelfUpdate,1989 versionMismatchSelfUpdate,
1967 versionMismatchServerLabel,1990 versionMismatchServerLabel,
1968 ]);1991 ]);
apps/web/src/components/ServerUpdateAction.test.tsx+81-134
...@@ -1,71 +1,15 @@...@@ -1,71 +1,15 @@
1import type { Dispatch, ReactElement, SetStateAction } from "react";1import type { ReactElement } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { renderToStaticMarkup } from "react-dom/server";
3import type { EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
5import type { EnvironmentId } from "@t3tools/contracts";6import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
67
7const testState = vi.hoisted(() => ({8const testState = vi.hoisted(() => ({
8 updateServer: vi.fn(),9 updateServer: vi.fn(),
9 toast: vi.fn(),10 toast: vi.fn(),
10}));11}));
1112
12const hooks = vi.hoisted(() => {
13 let cursor = 0;
14 let slots: unknown[] = [];
15 const nextIndex = () => cursor++;
16
17 return {
18 beginRender() {
19 cursor = 0;
20 },
21 reset() {
22 cursor = 0;
23 slots = [];
24 },
25 useEffect() {
26 nextIndex();
27 },
28 useMemoCache(size: number): unknown[] {
29 const index = nextIndex();
30 if (!slots[index]) {
31 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
32 }
33 return slots[index] as unknown[];
34 },
35 useRef<T>(initialValue: T): { current: T } {
36 const index = nextIndex();
37 if (!slots[index]) {
38 slots[index] = { current: initialValue };
39 }
40 return slots[index] as { current: T };
41 },
42 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
43 const index = nextIndex();
44 if (index >= slots.length) {
45 slots[index] =
46 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
47 }
48 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
49 const previous = slots[index] as T;
50 slots[index] =
51 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
52 };
53 return [slots[index] as T, setValue];
54 },
55 };
56});
57
58vi.mock("react", async (importOriginal) => {
59 const actual = await importOriginal<typeof import("react")>();
60 return {
61 ...actual,
62 useEffect: hooks.useEffect,
63 useRef: hooks.useRef,
64 useState: hooks.useState,
65 };
66});
67
68vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
69vi.mock("~/hooks/useCopyToClipboard", () => ({13vi.mock("~/hooks/useCopyToClipboard", () => ({
70 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),14 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
71}));15}));
...@@ -79,120 +23,123 @@ vi.mock("./ui/toast", () => ({...@@ -79,120 +23,123 @@ vi.mock("./ui/toast", () => ({
79 toastManager: { add: testState.toast },23 toastManager: { add: testState.toast },
80}));24}));
8125
82import { ServerUpdateAction } from "./ServerUpdateAction";26import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
8327
84type ActionElement = ReactElement<{28type ActionElement = ReactElement<{
85 readonly disabled?: boolean;
86 readonly onClick?: () => void;29 readonly onClick?: () => void;
87}>;30}>;
8831
89function renderAction(): ActionElement {32function renderAction(): ActionElement {
90 hooks.beginRender();
91 return ServerUpdateAction({33 return ServerUpdateAction({
92 environmentId: "env-test" as EnvironmentId,34 environmentId: "env-test" as EnvironmentId,
93 serverLabel: "Test server",35 serverLabel: "Test server",
94 selfUpdate: "boot-service",36 selfUpdate: "boot-service",
95 targetVersion: "0.0.29",37 targetVersion: "0.0.31",
96 }) as ActionElement;38 }) as ActionElement;
97}39}
9840
99function deferred<T>() {
100 let resolve!: (value: T) => void;
101 const promise = new Promise<T>((complete) => {
102 resolve = complete;
103 });
104 return { promise, resolve };
105}
106
107async function flushPromises(): Promise<void> {41async function flushPromises(): Promise<void> {
108 await Promise.resolve();42 await Promise.resolve();
109 await Promise.resolve();43 await Promise.resolve();
110 await Promise.resolve();
111 await Promise.resolve();
112 await Promise.resolve();
113}44}
11445
115describe("ServerUpdateAction", () => {46describe("ServerUpdateAction", () => {
116 beforeEach(() => {47 beforeEach(() => {
117 vi.useFakeTimers();
118 hooks.reset();
119 testState.updateServer.mockReset();48 testState.updateServer.mockReset();
120 testState.toast.mockReset();49 testState.toast.mockReset();
121 });50 });
12251
123 afterEach(() => {52 it("reports success only after the shared update flow reconnects", async () => {
124 vi.useRealTimers();53 testState.updateServer.mockResolvedValue(
125 });54 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
12655 );
127 it("starts a fresh reconnect timeout after a long install succeeds", async () => {
128 const update =
129 deferred<
130 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>
131 >();
132 testState.updateServer.mockReturnValue(update.promise);
13356
134 renderAction().props.onClick?.();57 renderAction().props.onClick?.();
135 expect(renderAction().props.disabled).toBe(true);
136
137 await vi.advanceTimersByTimeAsync(11 * 60_000);
138 update.resolve(
139 AsyncResult.success({
140 targetVersion: "0.0.29",
141 method: "boot-service",
142 }),
143 );
144 await flushPromises();58 await flushPromises();
14559
146 // The click-based deadline would have fired by now. Success gets a fresh60 expect(testState.updateServer).toHaveBeenCalledWith({
147 // twelve-minute reconnect window, so the action remains disabled.61 environmentId: "env-test",
148 await vi.advanceTimersByTimeAsync(2 * 60_000);62 input: { targetVersion: "0.0.31" },
149 expect(renderAction().props.disabled).toBe(true);63 });
150 expect(testState.toast).not.toHaveBeenCalledWith(64 expect(testState.toast).toHaveBeenCalledWith({
151 expect.objectContaining({ title: "Server update timed out" }),65 type: "success",
152 );66 title: "Test server updated",
15367 description: "Reconnected on t3@0.0.31.",
154 await vi.advanceTimersByTimeAsync(10 * 60_000);68 });
155 expect(renderAction().props.disabled).not.toBe(true);
156 expect(testState.toast).toHaveBeenCalledWith(
157 expect.objectContaining({ title: "Server update timed out" }),
158 );
159 });69 });
16070
161 it("does not let an expired request clear a newer retry", async () => {71 it("reports one result when the update action is double-clicked", async () => {
162 const first = deferred<ReturnType<typeof AsyncResult.failure>>();72 let finishUpdate: (() => void) | undefined;
163 const retry =73 testState.updateServer.mockImplementation(
164 deferred<74 () =>
165 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>75 new Promise((resolve) => {
166 >();76 finishUpdate = () =>
167 testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise);77 resolve(
16878 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
169 renderAction().props.onClick?.();79 );
170 await vi.advanceTimersByTimeAsync(12 * 60_000);80 }),
171 expect(renderAction().props.disabled).not.toBe(true);81 );
172
173 renderAction().props.onClick?.();
174 expect(renderAction().props.disabled).toBe(true);
175
176 first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late"))));
177 await flushPromises();
17882
179 expect(renderAction().props.disabled).toBe(true);83 const action = renderAction();
180 expect(testState.updateServer).toHaveBeenCalledTimes(2);84 action.props.onClick?.();
85 action.props.onClick?.();
18186
182 retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" }));87 expect(testState.updateServer).toHaveBeenCalledTimes(1);
88 finishUpdate?.();
183 await flushPromises();89 await flushPromises();
184 expect(renderAction().props.disabled).toBe(true);90 expect(testState.toast).toHaveBeenCalledTimes(1);
185 });91 });
18692
187 it("quietly releases the action when a restart RPC is interrupted", async () => {93 it("quietly releases the action when the operation is interrupted", async () => {
188 testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));94 testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));
18995
190 renderAction().props.onClick?.();96 renderAction().props.onClick?.();
191 await flushPromises();97 await flushPromises();
19298
193 expect(renderAction().props.disabled).not.toBe(true);99 expect(testState.toast).not.toHaveBeenCalled();
194 expect(testState.toast).not.toHaveBeenCalledWith(100 });
195 expect.objectContaining({ title: "Server update failed" }),101});
102
103describe("ServerUpdateProgress", () => {
104 it("renders the chosen horizontal step rail without an animated spinner", () => {
105 const markup = renderToStaticMarkup(
106 <ServerUpdateProgress
107 fromVersion="0.0.30"
108 serverLabel="bb-1"
109 state={{
110 status: "running",
111 stage: "resuming",
112 fromVersion: "0.0.30",
113 targetVersion: "0.0.31",
114 }}
115 />,
116 );
117
118 expect(markup).toContain("0.0.30");
119 expect(markup).toContain("0.0.31");
120 expect(markup).toContain("Download");
121 expect(markup).toContain("Install");
122 expect(markup).toContain("Resume");
123 expect(markup).toContain("Waiting for bb-1 to accept commands.");
124 expect(markup).not.toContain("animate-spin");
125 });
126
127 it("keeps the failed stage visible with its retryable error", () => {
128 const markup = renderToStaticMarkup(
129 <ServerUpdateProgress
130 fromVersion="0.0.30"
131 serverLabel="bb-1"
132 state={{
133 status: "failed",
134 stage: "installing",
135 fromVersion: "0.0.30",
136 targetVersion: "0.0.31",
137 message: "The package could not be verified.",
138 }}
139 />,
196 );140 );
141
142 expect(markup).toContain('role="alert"');
143 expect(markup).toContain("The package could not be verified.");
197 });144 });
198});145});
apps/web/src/components/ServerUpdateAction.tsx+132-117
...@@ -1,55 +1,147 @@...@@ -1,55 +1,147 @@
1import { useEffect, useRef, useState } from "react";
2import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";1import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import {3import {
4 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { CheckIcon } from "lucide-react";
78
8import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";9import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
10import { cn } from "~/lib/utils";
9import { serverEnvironment } from "~/state/server";11import { serverEnvironment } from "~/state/server";
10import { useAtomCommand } from "~/state/use-atom-command";12import { useAtomCommand } from "~/state/use-atom-command";
11import { manualServerUpdateCommand } from "~/versionSkew";13import { manualServerUpdateCommand } from "~/versionSkew";
12import { Button } from "./ui/button";14import { Button } from "./ui/button";
13import { Spinner } from "./ui/spinner";
14import { toastManager } from "./ui/toast";15import { toastManager } from "./ui/toast";
1516
16/**17const UPDATE_STEPS = [
17 * The npm install on the server side is capped at 10 minutes; expire the18 { stage: "downloading", label: "Download" },
18 * spinner a bit beyond that so a dead transport never strands a disabled19 { stage: "installing", label: "Install" },
19 * button, while a legitimately slow install is never cut off.20 { stage: "resuming", label: "Resume" },
20 */21] as const;
21const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000;22const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2223
23function updateFailureMessage(error: unknown): string {24function updateFailureMessage(error: unknown): string {
24 return error instanceof Error ? error.message : "Server update failed.";25 return error instanceof Error ? error.message : "Server update failed.";
25}26}
2627
28function updateStatusCopy(
29 state: Exclude<ServerUpdateState, { status: "idle" }>,
30 serverLabel: string,
31): string {
32 if (state.status === "failed") {
33 return state.message;
34 }
35 switch (state.stage) {
36 case "downloading":
37 return "Downloading the matching server version.";
38 case "installing":
39 return `Installing and verifying t3@${state.targetVersion}.`;
40 case "resuming":
41 return `Waiting for ${serverLabel} to accept commands.`;
42 }
43}
44
45export function ServerUpdateProgress({
46 fromVersion,
47 serverLabel,
48 state,
49}: {
50 readonly fromVersion: string;
51 readonly serverLabel: string;
52 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
53}) {
54 const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);
55
56 return (
57 <div className="mt-1 min-w-0 space-y-2.5">
58 <p className="text-xs text-muted-foreground">
59 {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
60 </p>
61 <ol className="flex min-w-0 items-center" aria-label="Server update progress">
62 {UPDATE_STEPS.map((step, index) => {
63 const complete = index < currentIndex;
64 const current = index === currentIndex;
65 const failed = current && state.status === "failed";
66 return (
67 <li
68 key={step.stage}
69 className={cn(
70 "flex min-w-0 items-center text-xs font-medium",
71 index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
72 complete
73 ? "text-success"
74 : failed
75 ? "text-destructive"
76 : current
77 ? "text-primary"
78 : "text-muted-foreground/60",
79 )}
80 aria-current={current && state.status === "running" ? "step" : undefined}
81 >
82 <span
83 className={cn(
84 "grid size-4 shrink-0 place-items-center rounded-full border",
85 complete
86 ? "border-success bg-success text-success-foreground"
87 : failed
88 ? "border-destructive bg-destructive text-destructive-foreground"
89 : current
90 ? "border-primary bg-primary text-primary-foreground"
91 : "border-muted-foreground/35",
92 )}
93 aria-hidden="true"
94 >
95 {complete ? <CheckIcon className="size-3" strokeWidth={3} /> : null}
96 </span>
97 <span className="ml-1.5 truncate">{step.label}</span>
98 {index < UPDATE_STEPS.length - 1 ? (
99 <span
100 className={cn(
101 "mx-2 h-px min-w-3 flex-1",
102 complete ? "bg-success/60" : "bg-border",
103 )}
104 aria-hidden="true"
105 />
106 ) : null}
107 </li>
108 );
109 })}
110 </ol>
111 <p
112 className={cn(
113 "text-xs",
114 state.status === "failed" ? "text-destructive" : "text-muted-foreground",
115 )}
116 role={state.status === "failed" ? "alert" : "status"}
117 >
118 {updateStatusCopy(state, serverLabel)}
119 </p>
120 </div>
121 );
122}
123
27/**124/**
28 * The call-to-action for a version-skewed server, matched to the update path125 * Offers the update path advertised by a version-skewed server. Self-updates
29 * it advertises: a one-click install-and-restart for servers that can update126 * delegate their full lifecycle to client-runtime so this component can
30 * themselves, an update-the-desktop-app hint for desktop-managed backends127 * unmount during reconnect without losing operation state.
31 * (running `npx t3` there would start a second server, not update this one),
32 * and copying the manual relaunch command for everything else — so the skew
33 * warning always offers a way out.
34 */128 */
35export function ServerUpdateAction({129export function ServerUpdateAction({
36 environmentId,130 environmentId,
37 serverLabel,131 serverLabel,
38 selfUpdate,132 selfUpdate,
39 targetVersion,133 targetVersion,
134 label = "Update server",
40}: {135}: {
41 readonly environmentId: EnvironmentId;136 readonly environmentId: EnvironmentId;
42 readonly serverLabel: string;137 readonly serverLabel: string;
43 readonly selfUpdate: ServerSelfUpdateCapability | null;138 readonly selfUpdate: ServerSelfUpdateCapability | null;
44 readonly targetVersion: string;139 readonly targetVersion: string;
140 readonly label?: string;
45}) {141}) {
46 const updateServer = useAtomCommand(serverEnvironment.updateServer, {142 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
47 reportFailure: false,143 reportFailure: false,
48 });144 });
49 const [pending, setPending] = useState(false);
50 const inFlightRef = useRef(false);
51 const attemptRef = useRef(0);
52 const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({145 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
54 target: "update command",146 target: "update command",
55 onCopy: ({ command }) => {147 onCopy: ({ command }) => {
...@@ -68,107 +160,35 @@ export function ServerUpdateAction({...@@ -68,107 +160,35 @@ export function ServerUpdateAction({
68 },160 },
69 });161 });
70162
71 useEffect(163 const handleUpdate = async () => {
72 () => () => {164 if (pendingUpdateEnvironmentIds.has(environmentId)) {
73 if (expiryRef.current !== null) {
74 clearTimeout(expiryRef.current);
75 expiryRef.current = null;
76 }
77 attemptRef.current += 1;
78 inFlightRef.current = false;
79 },
80 [],
81 );
82
83 const handleUpdate = () => {
84 // Synchronous re-entry guard: setPending is async, so a rapid
85 // double-click would otherwise dispatch two updates.
86 if (inFlightRef.current) {
87 return;165 return;
88 }166 }
89 inFlightRef.current = true;167 pendingUpdateEnvironmentIds.add(environmentId);
90 const attempt = attemptRef.current + 1;168 try {
91 attemptRef.current = attempt;169 const result = await updateServer({
92 const ownsAttempt = () => attemptRef.current === attempt;170 environmentId,
93 setPending(true);171 input: { targetVersion },
94 const armExpiry = () => {172 });
95 const expiry = setTimeout(() => {173 if (result._tag === "Failure") {
96 if (!ownsAttempt()) return;174 if (isAtomCommandInterrupted(result)) {
97 expiryRef.current = null;
98 attemptRef.current += 1;
99 inFlightRef.current = false;
100 setPending(false);
101 toastManager.add({
102 type: "error",
103 title: "Server update timed out",
104 description: "The update may still be running on the server — check again in a minute.",
105 });
106 }, UPDATE_PENDING_EXPIRY_MS);
107 expiryRef.current = expiry;
108 return expiry;
109 };
110 let expiry = armExpiry();
111 let restartAccepted = false;
112 const keepPendingForRestart = () => {
113 restartAccepted = true;
114 if (expiryRef.current === expiry) {
115 clearTimeout(expiry);
116 expiry = armExpiry();
117 }
118 };
119 void Promise.resolve()
120 .then(() =>
121 updateServer({
122 environmentId,
123 input: { targetVersion },
124 }),
125 )
126 .then((result) => {
127 if (!ownsAttempt()) return;
128 if (result._tag === "Failure") {
129 // An interrupt may be the expected boot-service disconnect, but it
130 // can also be client-side cancellation before restart was accepted.
131 // Release the action quietly; version sync will remove it when a
132 // successful replacement reconnects.
133 if (isAtomCommandInterrupted(result)) {
134 return;
135 }
136 toastManager.add({
137 type: "error",
138 title: "Server update failed",
139 description: updateFailureMessage(squashAtomCommandFailure(result)),
140 });
141 return;175 return;
142 }176 }
143 keepPendingForRestart();
144 // Installation can legitimately consume most of the request window.
145 // Give restart/reconnect a fresh full window after the server accepts
146 // the handoff instead of expiring based on the original click time.
147 toastManager.add({
148 type: "success",
149 title: `Updating ${serverLabel}`,
150 description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`,
151 });
152 })
153 .catch((error: unknown) => {
154 if (!ownsAttempt()) return;
155 toastManager.add({177 toastManager.add({
156 type: "error",178 type: "error",
157 title: "Server update failed",179 title: "Server update failed",
158 description: updateFailureMessage(error),180 description: updateFailureMessage(squashAtomCommandFailure(result)),
159 });181 });
160 })182 return;
161 .finally(() => {183 }
162 // A successful RPC only acknowledges that restart is scheduled. Keep184 toastManager.add({
163 // the action disabled until version sync unmounts it, or until the185 type: "success",
164 // safety expiry reports that reconnection never arrived.186 title: `${serverLabel} updated`,
165 if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return;187 description: `Reconnected on t3@${result.value.targetVersion}.`,
166 expiryRef.current = null;
167 clearTimeout(expiry);
168 attemptRef.current += 1;
169 inFlightRef.current = false;
170 setPending(false);
171 });188 });
189 } finally {
190 pendingUpdateEnvironmentIds.delete(environmentId);
191 }
172 };192 };
173193
174 if (selfUpdate === "desktop-managed") {194 if (selfUpdate === "desktop-managed") {
...@@ -188,14 +208,9 @@ export function ServerUpdateAction({...@@ -188,14 +208,9 @@ export function ServerUpdateAction({
188 );208 );
189 }209 }
190210
191 return pending ? (211 return (
192 <Button size="xs" disabled>
193 <Spinner className="size-3.5" />
194 Updating...
195 </Button>
196 ) : (
197 <Button size="xs" onClick={() => void handleUpdate()}>212 <Button size="xs" onClick={() => void handleUpdate()}>
198 Update server213 {label}
199 </Button>214 </Button>
200 );215 );
201}216}
apps/web/src/components/settings/ConnectionsSettings.tsx+50-13
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 TerminalIcon,7 TerminalIcon,
8 TriangleAlertIcon,8 TriangleAlertIcon,
9} from "lucide-react";9} from "lucide-react";
10import { useAtomValue } from "@effect/atom-react";
10import { type ReactNode, memo, useCallback, useMemo, useState } from "react";11import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
11import {12import {
12 AuthAccessReadScope,13 AuthAccessReadScope,
...@@ -131,8 +132,9 @@ import {...@@ -131,8 +132,9 @@ import {
131 usePrimaryEnvironment,132 usePrimaryEnvironment,
132} from "~/state/environments";133} from "~/state/environments";
133import { useAtomCommand } from "../../state/use-atom-command";134import { useAtomCommand } from "../../state/use-atom-command";
135import { serverEnvironment } from "~/state/server";
134import { ConnectionStatusDot } from "../ConnectionStatusDot";136import { ConnectionStatusDot } from "../ConnectionStatusDot";
135import { ServerUpdateAction } from "../ServerUpdateAction";137import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";
136import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";138import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
137import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";139import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
138140
...@@ -1389,6 +1391,9 @@ function SavedBackendListRow({...@@ -1389,6 +1391,9 @@ function SavedBackendListRow({
1389 [copyTraceIdToClipboard],1391 [copyTraceIdToClipboard],
1390 );1392 );
1391 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);1393 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
1394 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
1395 const resumingServerUpdate =
1396 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1392 const sshTarget =1397 const sshTarget =
1393 environment.entry.target._tag === "SshConnectionTarget" &&1398 environment.entry.target._tag === "SshConnectionTarget" &&
1394 Option.isSome(environment.entry.profile) &&1399 Option.isSome(environment.entry.profile) &&
...@@ -1425,14 +1430,22 @@ function SavedBackendListRow({...@@ -1425,14 +1430,22 @@ function SavedBackendListRow({
1425 {metadataBits.length > 0 ? (1430 {metadataBits.length > 0 ? (
1426 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>1431 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
1427 ) : null}1432 ) : null}
1428 {versionMismatch ? (1433 {serverUpdateState.status !== "idle" ? (
1434 <div className="max-w-md">
1435 <ServerUpdateProgress
1436 fromVersion={serverUpdateState.fromVersion}
1437 serverLabel={`${environment.label} server`}
1438 state={serverUpdateState}
1439 />
1440 </div>
1441 ) : versionMismatch ? (
1429 <p className="flex items-center gap-1 text-warning text-xs">1442 <p className="flex items-center gap-1 text-warning text-xs">
1430 <TriangleAlertIcon className="size-3.5 shrink-0" />1443 <TriangleAlertIcon className="size-3.5 shrink-0" />
1431 Version drift: client {versionMismatch.clientVersion}, server{" "}1444 Version drift: client {versionMismatch.clientVersion}, server{" "}
1432 {versionMismatch.serverVersion}.1445 {versionMismatch.serverVersion}.
1433 </p>1446 </p>
1434 ) : null}1447 ) : null}
1435 {environment.connection.error ? (1448 {environment.connection.error && !resumingServerUpdate ? (
1436 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1449 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
1437 <span className="truncate">{connectionStatusText(environment.connection)}</span>1450 <span className="truncate">{connectionStatusText(environment.connection)}</span>
1438 {errorTraceId ? (1451 {errorTraceId ? (
...@@ -1448,12 +1461,14 @@ function SavedBackendListRow({...@@ -1448,12 +1461,14 @@ function SavedBackendListRow({
1448 ) : null}1461 ) : null}
1449 </div>1462 </div>
1450 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">1463 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1451 {versionMismatch ? (1464 {versionMismatch &&
1465 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? (
1452 <ServerUpdateAction1466 <ServerUpdateAction
1453 environmentId={environmentId}1467 environmentId={environmentId}
1454 serverLabel={`${environment.label} server`}1468 serverLabel={`${environment.label} server`}
1455 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}1469 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1456 targetVersion={versionMismatch.clientVersion}1470 targetVersion={versionMismatch.clientVersion}
1471 label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"}
1457 />1472 />
1458 ) : null}1473 ) : null}
1459 {isWslEnvironment ? (1474 {isWslEnvironment ? (
...@@ -1834,6 +1849,9 @@ export function ConnectionsSettings() {...@@ -1834,6 +1849,9 @@ export function ConnectionsSettings() {
1834 >(null);1849 >(null);
1835 const primaryServerConfig = primaryEnvironment?.serverConfig ?? null;1850 const primaryServerConfig = primaryEnvironment?.serverConfig ?? null;
1836 const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig);1851 const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig);
1852 const primaryServerUpdateState = useAtomValue(
1853 serverEnvironment.updateStateAtom(primaryEnvironmentId),
1854 );
1837 const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);1855 const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);
1838 const defaultAdvertisedEndpointKey = useUiStateStore(1856 const defaultAdvertisedEndpointKey = useUiStateStore(
1839 (state) => state.defaultAdvertisedEndpointKey,1857 (state) => state.defaultAdvertisedEndpointKey,
...@@ -2980,24 +2998,43 @@ export function ConnectionsSettings() {...@@ -2980,24 +2998,43 @@ export function ConnectionsSettings() {
2980 {canManageLocalBackend ? (2998 {canManageLocalBackend ? (
2981 <>2999 <>
2982 <SettingsSection title="This environment">3000 <SettingsSection title="This environment">
2983 {primaryVersionMismatch ? (3001 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
2984 <SettingsRow3002 <SettingsRow
2985 title="Version drift"3003 title={
3004 primaryServerUpdateState.status === "failed"
3005 ? "Update failed"
3006 : primaryServerUpdateState.status === "running"
3007 ? "Updating server"
3008 : "Version drift"
3009 }
2986 description={3010 description={
2987 <span className="flex items-center gap-1 text-warning">3011 primaryServerUpdateState.status !== "idle" ? (
2988 <TriangleAlertIcon className="size-3.5 shrink-0" />3012 <ServerUpdateProgress
2989 Client {primaryVersionMismatch.clientVersion}, server{" "}3013 fromVersion={primaryServerUpdateState.fromVersion}
2990 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects3014 serverLabel={primaryEnvironment?.label ?? "this server"}
2991 fail.3015 state={primaryServerUpdateState}
2992 </span>3016 />
3017 ) : primaryVersionMismatch ? (
3018 <span className="flex items-center gap-1 text-warning">
3019 <TriangleAlertIcon className="size-3.5 shrink-0" />
3020 Client {primaryVersionMismatch.clientVersion}, server{" "}
3021 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
3022 fail.
3023 </span>
3024 ) : null
2993 }3025 }
2994 control={3026 control={
2995 primaryEnvironmentId !== null ? (3027 primaryVersionMismatch &&
3028 primaryEnvironmentId !== null &&
3029 primaryServerUpdateState.status !== "running" ? (
2996 <ServerUpdateAction3030 <ServerUpdateAction
2997 environmentId={primaryEnvironmentId}3031 environmentId={primaryEnvironmentId}
2998 serverLabel={primaryEnvironment?.label ?? "this server"}3032 serverLabel={primaryEnvironment?.label ?? "this server"}
2999 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}3033 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3000 targetVersion={primaryVersionMismatch.clientVersion}3034 targetVersion={primaryVersionMismatch.clientVersion}
3035 {...(primaryServerUpdateState.status === "failed"
3036 ? { label: "Retry update" }
3037 : {})}
3001 />3038 />
3002 ) : undefined3039 ) : undefined
3003 }3040 }
docs/architecture/server-updates.md+32-22
...@@ -13,8 +13,9 @@ The feature has three boundaries:...@@ -13,8 +13,9 @@ The feature has three boundaries:
13## Detection and Presentation13## Detection and Presentation
1414
15`ExecutionEnvironmentDescriptor` includes the server version and an optional15`ExecutionEnvironmentDescriptor` includes the server version and an optional
16`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after16`capabilities.serverSelfUpdate` value. Progress-capable servers also advertise
17loading server config.17`capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION`
18after loading server config.
1819
19The optional capability is intentionally backward compatible. An older server does not know about20The optional capability is intentionally backward compatible. An older server does not know about
20the field, so a missing value means the client must offer a manual relaunch instead of sending an21the field, so a missing value means the client must offer a manual relaunch instead of sending an
...@@ -28,6 +29,10 @@ The shared `ServerUpdateAction` is rendered in both user-facing version-drift su...@@ -28,6 +29,10 @@ The shared `ServerUpdateAction` is rendered in both user-facing version-drift su
28Both surfaces target the client's exact version. When the reconnected server reports that version,29Both surfaces target the client's exact version. When the reconnected server reports that version,
29the mismatch and action disappear.30the mismatch and action disappear.
3031
32The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read
33the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the
34operation.
35
31## Capability Selection36## Capability Selection
3237
33The server resolves its capability once at startup and publishes it in the environment descriptor.38The server resolves its capability once at startup and publishes it in the environment descriptor.
...@@ -51,20 +56,25 @@ flowchart TD...@@ -51,20 +56,25 @@ flowchart TD
51 A[Client detects different versions] --> B{Advertised update path}56 A[Client detects different versions] --> B{Advertised update path}
52 B -->|desktop-managed| C[Update desktop app on server machine]57 B -->|desktop-managed| C[Update desktop app on server machine]
53 B -->|missing| D[Copy exact manual relaunch command]58 B -->|missing| D[Copy exact manual relaunch command]
54 B -->|boot-service or respawn| E[server.updateServer]59 B -->|boot-service or respawn| E{Progress capability}
55 E --> F[Install exact t3 version in pinned runtime]60 E -->|present| F[server.updateServerWithProgress]
56 F --> G[Run version preflight]61 E -->|missing| G[server.updateServer fallback]
57 G -->|fails| H[Remove failed runtime and keep current server]62 F --> H[Download exact t3 version]
58 G -->|passes| I{Handoff method}63 G --> H
59 I -->|boot-service| J[Rewrite and restart T3 systemd unit]64 H --> I[Install and run version preflight]
60 I -->|respawn| K[Start delayed replacement and exit current process]65 I -->|fails| J[Remove failed runtime and keep current server]
61 J --> L[Client reconnects]66 I -->|passes| K{Handoff method}
62 K --> L67 K -->|boot-service| L[Rewrite and restart T3 systemd unit]
68 K -->|respawn| M[Start delayed replacement and exit current process]
69 L --> N[Reconnect with fresh backoff]
70 M --> N
71 N --> O[Replacement publishes ready at target version]
63```72```
6473
65`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its74Both update RPCs require the environment's `orchestration:operate` authorization scope. Their
66payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as75payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as
67`latest` and `nightly` are rejected.76`latest` and `nightly` are rejected. The unary `server.updateServer` method remains available so a
77new client can still repair skew with an older server.
6878
69The update service permits one update at a time. It installs `t3@<version>` under79The update service permits one update at a time. It installs `t3@<version>` under
70`<baseDir>/runtime/versions/<version>` and writes an install-complete sentinel only after npm exits80`<baseDir>/runtime/versions/<version>` and writes an install-complete sentinel only after npm exits
...@@ -89,20 +99,20 @@ authorization; it does not uninstall the host service....@@ -89,20 +99,20 @@ authorization; it does not uninstall the host service.
89## Process Handoff99## Process Handoff
90100
91For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified101For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified
92runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous102runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same
93unit before returning an error.103short grace period used by foreground respawn. A rejected deferred restart restores the previous
104unit and is logged by the still-running process.
94105
95For `respawn`, the server starts a detached, delayed replacement that replays the original CLI106For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
96arguments. It then acknowledges the request and schedules the current process to exit. The delays107arguments. It then acknowledges the request and schedules the current process to exit. The delays
97give the acknowledgement time to cross direct or relayed connections before the socket closes.108give the acknowledgement time to cross direct or relayed connections before the socket closes.
98109
99There is no separate progress stream. The update request remains pending while npm installs and the110Progress-capable servers emit `downloading` before installing the pinned runtime and `installing`
100client shows a disabled update action. A restart can interrupt the request normally; the connection111before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The
101runtime keeps the environment registered and reconnects through its usual retry path. After an112client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with
102acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a113the target version, and only then completes the operation. It watches for the intentional
103safety timeout releases it. If a boot-service restart closes the connection before acknowledgement,114disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt
104the UI releases the interrupted action without reporting a false update failure and lets reconnect115without adding a separate reconnect loop.
105and the next version check determine the result.
106116
107## Release Invariant117## Release Invariant
108118
docs/user/server-updates.md+9-4
...@@ -31,6 +31,11 @@ The update does not remove saved threads, settings, or project files....@@ -31,6 +31,11 @@ The update does not remove saved threads, settings, or project files.
31The available action depends on how that server was started. T3 Code does not update connected31The available action depends on how that server was started. T3 Code does not update connected
32servers silently in the background.32servers silently in the background.
3333
34After selecting **Update server**, the warning becomes a three-step progress rail:
35**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in
36Connections, so navigating between them does not lose the update. A failed step remains visible
37with its error and an option to retry.
38
34If the server uses the T3 Code background service, you can also update it directly on the host:39If the server uses the T3 Code background service, you can also update it directly on the host:
3540
36```sh41```sh
...@@ -42,11 +47,11 @@ commands....@@ -42,11 +47,11 @@ commands.
4247
43## After the Update48## After the Update
4449
45Keep the web or desktop app open while the server restarts. When it reconnects with the matching50Keep the web or desktop app open while the server restarts. The update completes only after the
46version, the warning and update action disappear.51replacement server reports the requested version and is ready to accept commands. The warning and
52progress rail then disappear.
4753
48If the client reports a timeout, the server may still be finishing the update. Wait a minute, then54If a step fails:
49reconnect or open **Settings** → **Connections** again. If the warning remains:
5055
511. Retry the offered action once.561. Retry the offered action once.
522. Make sure you updated the machine named in the warning, not only the device you are using.572. Make sure you updated the machine named in the warning, not only the device you are using.
packages/client-runtime/src/connection/supervisor.test.ts+37
...@@ -513,6 +513,43 @@ describe("EnvironmentSupervisor", () => {...@@ -513,6 +513,43 @@ describe("EnvironmentSupervisor", () => {
513 }),513 }),
514 );514 );
515515
516 it.effect("explicit retry starts a fresh backoff sequence", () =>
517 Effect.gen(function* () {
518 const harness = yield* makeHarness({
519 prepare: () => Effect.fail(transient()),
520 });
521 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
522 initiallyDesired: true,
523 }).pipe(Effect.provide(harness.dependencies));
524
525 yield* awaitState(
526 supervisor.state,
527 (state) => state.phase === "backoff" && state.attempt === 1,
528 );
529 yield* TestClock.adjust("1 second");
530 yield* eventuallyState(
531 supervisor.state,
532 (state) => state.phase === "backoff" && state.attempt === 2,
533 );
534
535 yield* supervisor.retryNow;
536 yield* eventuallyState(
537 supervisor.state,
538 (state) => state.phase === "backoff" && state.attempt === 1,
539 );
540 expect(yield* Ref.get(harness.prepareCount)).toBe(3);
541
542 yield* TestClock.adjust("999 millis");
543 expect(yield* Ref.get(harness.prepareCount)).toBe(3);
544 yield* TestClock.adjust("1 milli");
545 yield* eventuallyState(
546 supervisor.state,
547 (state) => state.phase === "backoff" && state.attempt === 2,
548 );
549 expect(yield* Ref.get(harness.prepareCount)).toBe(4);
550 }).pipe(Effect.provide(TestClock.layer())),
551 );
552
516 it.effect("keeps blocked failures idle until an external signal requests another attempt", () =>553 it.effect("keeps blocked failures idle until an external signal requests another attempt", () =>
517 Effect.gen(function* () {554 Effect.gen(function* () {
518 const harness = yield* makeHarness({555 const harness = yield* makeHarness({
packages/client-runtime/src/connection/supervisor.ts+8-1
...@@ -231,6 +231,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -231,6 +231,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
231 };231 };
232 const intent = yield* Ref.make(initialIntent);232 const intent = yield* Ref.make(initialIntent);
233 const signals = yield* Queue.unbounded<SupervisorSignal>();233 const signals = yield* Queue.unbounded<SupervisorSignal>();
234 const resetRetryState = yield* Ref.make(false);
234 const state = yield* SubscriptionRef.make<SupervisorConnectionState>(235 const state = yield* SubscriptionRef.make<SupervisorConnectionState>(
235 !initialIntent.desired236 !initialIntent.desired
236 ? availableState(initialIntent, 0)237 ? availableState(initialIntent, 0)
...@@ -643,6 +644,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -643,6 +644,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
643 };644 };
644645
645 for (;;) {646 for (;;) {
647 if (yield* Ref.getAndSet(resetRetryState, false)) {
648 failureCount = 0;
649 latestFailure = null;
650 pendingRetry = Option.none();
651 }
646 const currentIntent = yield* Ref.get(intent);652 const currentIntent = yield* Ref.get(intent);
647 if (!currentIntent.desired) {653 if (!currentIntent.desired) {
648 resetRetryLadder();654 resetRetryLadder();
...@@ -763,7 +769,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -763,7 +769,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
763 Effect.withSpan("EnvironmentSupervisor.disconnect"),769 Effect.withSpan("EnvironmentSupervisor.disconnect"),
764 );770 );
765771
766 const retryNow = signal({ _tag: "RetryRequested" }).pipe(772 const retryNow = Ref.set(resetRetryState, true).pipe(
773 Effect.andThen(signal({ _tag: "RetryRequested" })),
767 Effect.withSpan("EnvironmentSupervisor.retryNow"),774 Effect.withSpan("EnvironmentSupervisor.retryNow"),
768 );775 );
769776
packages/client-runtime/src/rpc/client.ts+2-1
...@@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag =
5656
57export type EnvironmentStreamCommandRpcTag =57export type EnvironmentStreamCommandRpcTag =
58 | typeof WS_METHODS.cloudInstallRelayClient58 | typeof WS_METHODS.cloudInstallRelayClient
59 | typeof WS_METHODS.serverUpdateServerWithProgress
59 | typeof WS_METHODS.gitRunStackedAction;60 | typeof WS_METHODS.gitRunStackedAction;
6061
61export type EnvironmentStreamRpcTag =62export type EnvironmentStreamRpcTag =
...@@ -80,7 +81,7 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{...@@ -80,7 +81,7 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{
80 }),81 }),
81}) {}82}) {}
8283
83const isRpcClientError = Schema.is(RpcClientError.RpcClientError);84export const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
8485
85export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];86export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
8687
packages/client-runtime/src/state/runtime.test.ts+49
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 environmentRpcKey,13 environmentRpcKey,
14 createAtomCommandScheduler,14 createAtomCommandScheduler,
15 createRuntimeCommand,15 createRuntimeCommand,
16 scheduleAtomCommandEffect,
16 executeAtomCommand,17 executeAtomCommand,
17 executeAtomQuery,18 executeAtomQuery,
18 isAtomCommandInterrupted,19 isAtomCommandInterrupted,
...@@ -399,6 +400,54 @@ describe("runtime command runner", () => {...@@ -399,6 +400,54 @@ describe("runtime command runner", () => {
399 registry.dispose();400 registry.dispose();
400 });401 });
401402
403 it.effect("releases a shared scheduler lane before the outer command finishes", () =>
404 Effect.gen(function* () {
405 const handoffStarted = Latch.makeUnsafe();
406 const handoffComplete = Latch.makeUnsafe();
407 const resumeComplete = Latch.makeUnsafe();
408 const runtime = Atom.runtime(Layer.empty);
409 const scheduler = createAtomCommandScheduler();
410 const concurrency = { mode: "serial" as const, key: () => "shared" };
411 const updateCommand = createRuntimeCommand(runtime, {
412 label: "test.update",
413 execute: (_input: void, registry) =>
414 scheduleAtomCommandEffect(
415 registry,
416 scheduler,
417 concurrency,
418 undefined,
419 Effect.sync(() => handoffStarted.openUnsafe()).pipe(
420 Effect.andThen(handoffComplete.await),
421 ),
422 ).pipe(Effect.andThen(resumeComplete.await)),
423 });
424 const configCommand = createRuntimeCommand(runtime, {
425 label: "test.config",
426 scheduler,
427 concurrency,
428 execute: () => Effect.succeed("configured"),
429 });
430 const registry = AtomRegistry.make();
431
432 const update = updateCommand.run(registry, undefined);
433 yield* handoffStarted.await;
434 const config = configCommand.run(registry, undefined);
435 handoffComplete.openUnsafe();
436
437 expect(yield* Effect.promise(() => config)).toMatchObject({
438 _tag: "Success",
439 value: "configured",
440 waiting: false,
441 });
442 resumeComplete.openUnsafe();
443 expect(yield* Effect.promise(() => update)).toMatchObject({
444 _tag: "Success",
445 waiting: false,
446 });
447 registry.dispose();
448 }),
449 );
450
402 it("deduplicates single-flight commands by key", async () => {451 it("deduplicates single-flight commands by key", async () => {
403 const latch = Latch.makeUnsafe();452 const latch = Latch.makeUnsafe();
404 let executions = 0;453 let executions = 0;
packages/client-runtime/src/state/runtime.ts+22
...@@ -253,6 +253,28 @@ export function createAtomCommandScheduler(): AtomCommandScheduler {...@@ -253,6 +253,28 @@ export function createAtomCommandScheduler(): AtomCommandScheduler {
253 };253 };
254}254}
255255
256/** Runs one effect inside an existing command scheduler lane. */
257export function scheduleAtomCommandEffect<W, A, E, R>(
258 registry: AtomRegistry.AtomRegistry,
259 scheduler: AtomCommandScheduler,
260 concurrency: AtomCommandConcurrency<W>,
261 input: W,
262 effect: Effect.Effect<A, E, R>,
263): Effect.Effect<A, E, R> {
264 return Effect.gen(function* () {
265 const context = yield* Effect.context<R>();
266 const result = yield* Effect.promise((signal) =>
267 scheduler.schedule<W, A, E>(registry, concurrency, input, async () => {
268 const exit = await Effect.runPromiseExitWith(context)(effect, { signal });
269 return Exit.isSuccess(exit)
270 ? AsyncResult.success(exit.value)
271 : AsyncResult.failure(exit.cause);
272 }),
273 );
274 return result._tag === "Success" ? result.value : yield* Effect.failCause(result.cause);
275 });
276}
277
256export async function runAtomCommand<W, A, E>(278export async function runAtomCommand<W, A, E>(
257 registry: AtomRegistry.AtomRegistry,279 registry: AtomRegistry.AtomRegistry,
258 command: AtomCommand<W, A, E>,280 command: AtomCommand<W, A, E>,
packages/client-runtime/src/state/server.test.ts+131-3
...@@ -6,11 +6,15 @@ import {...@@ -6,11 +6,15 @@ import {
6 WS_METHODS,6 WS_METHODS,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
10import * as Option from "effect/Option";12import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";13import * as Queue from "effect/Queue";
12import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";15import * as SubscriptionRef from "effect/SubscriptionRef";
16import { RpcClientError } from "effect/unstable/rpc";
17import * as Socket from "effect/unstable/socket/Socket";
1418
15import {19import {
16 AVAILABLE_CONNECTION_STATE,20 AVAILABLE_CONNECTION_STATE,
...@@ -24,8 +28,12 @@ import type { RpcSession } from "../rpc/session.ts";...@@ -24,8 +28,12 @@ import type { RpcSession } from "../rpc/session.ts";
24import {28import {
25 applyServerConfigProjection,29 applyServerConfigProjection,
26 makeEnvironmentServerConfigState,30 makeEnvironmentServerConfigState,
31 isLegacyUpdateHandoffLoss,
27 projectServerWelcome,32 projectServerWelcome,
28 resolveServerConfigValue,33 resolveServerConfigValue,
34 resolveServerUpdateProgressResult,
35 serverUpdateStateForProgressEvent,
36 serverUpdateStateForServerVersion,
29} from "./server.ts";37} from "./server.ts";
3038
31const CONFIG = {39const CONFIG = {
...@@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession {...@@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession {
62}70}
6371
64describe("server state projection", () => {72describe("server state projection", () => {
73 it("only treats a legacy transport interruption as an unacknowledged handoff", () => {
74 expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true);
75 expect(
76 isLegacyUpdateHandoffLoss(
77 Cause.fail(
78 new RpcClientError.RpcClientError({
79 reason: new Socket.SocketCloseError({ code: 1006 }),
80 }),
81 ),
82 ),
83 ).toBe(true);
84 expect(
85 isLegacyUpdateHandoffLoss(
86 Cause.fail(
87 new RpcClientError.RpcClientError({
88 reason: new Socket.SocketOpenError({
89 kind: "Unknown",
90 cause: new Error("connection refused"),
91 }),
92 }),
93 ),
94 ),
95 ).toBe(false);
96 expect(
97 isLegacyUpdateHandoffLoss(
98 Cause.fail(
99 new RpcClientError.RpcClientError({
100 reason: new RpcClientError.RpcClientDefect({
101 message: "incompatible protocol",
102 cause: new Error("invalid response"),
103 }),
104 }),
105 ),
106 ),
107 ).toBe(false);
108 expect(isLegacyUpdateHandoffLoss(Cause.fail(new Error("Install failed.")))).toBe(false);
109 });
110
111 it.effect("resumes after the progress stream disconnects following completion", () => {
112 const result = {
113 targetVersion: "0.0.31",
114 method: "respawn" as const,
115 };
116 const disconnect = new RpcClientError.RpcClientError({
117 reason: new Socket.SocketCloseError({ code: 1006 }),
118 });
119
120 return Effect.gen(function* () {
121 const resumed = yield* resolveServerUpdateProgressResult(
122 result.targetVersion,
123 Option.some(result),
124 Exit.fail(disconnect),
125 );
126 expect(resumed).toEqual(result);
127 });
128 });
129
130 it("projects streamed update milestones into the shared operation state", () => {
131 expect(
132 serverUpdateStateForProgressEvent("0.0.30", "0.0.31", {
133 type: "progress",
134 stage: "installing",
135 }),
136 ).toEqual({
137 status: "running",
138 stage: "installing",
139 fromVersion: "0.0.30",
140 targetVersion: "0.0.31",
141 });
142 expect(
143 serverUpdateStateForProgressEvent("0.0.30", "0.0.31", {
144 type: "complete",
145 result: { targetVersion: "0.0.31", method: "respawn" },
146 }),
147 ).toEqual({
148 status: "running",
149 stage: "resuming",
150 fromVersion: "0.0.30",
151 targetVersion: "0.0.31",
152 });
153 });
154
155 it("keeps active update state and hides stale failures after a version change", () => {
156 const running = {
157 status: "running" as const,
158 stage: "resuming" as const,
159 fromVersion: "0.0.30",
160 targetVersion: "0.0.31",
161 };
162 const failed = {
163 status: "failed" as const,
164 stage: "installing" as const,
165 fromVersion: "0.0.30",
166 targetVersion: "0.0.31",
167 message: "Install failed.",
168 };
169
170 expect(serverUpdateStateForServerVersion(running, "0.0.31")).toBe(running);
171 expect(serverUpdateStateForServerVersion(failed, "0.0.30")).toBe(failed);
172 expect(serverUpdateStateForServerVersion(failed, null)).toBe(failed);
173 expect(serverUpdateStateForServerVersion(failed, "0.0.31")).toEqual({ status: "idle" });
174 });
175
65 it("applies every config category to the projected snapshot", () => {176 it("applies every config category to the projected snapshot", () => {
66 const snapshot = applyServerConfigProjection(Option.none(), {177 const snapshot = applyServerConfigProjection(Option.none(), {
67 version: 1,178 version: 1,
...@@ -100,9 +211,16 @@ describe("server state projection", () => {...@@ -100,9 +211,16 @@ describe("server state projection", () => {
100 });211 });
101212
102 it("prefers an active session config over cache until a live event arrives", () => {213 it("prefers an active session config over cache until a live event arrives", () => {
103 const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig;214 const config = (source: string, serverVersion: string) =>
104 const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig;215 ({
105 const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig;216 ...CONFIG,
217 environment: { serverVersion },
218 settings: { source },
219 }) as unknown as ServerConfig;
220 const cached = config("cache", "0.0.29");
221 const staleLive = config("stale-live", "0.0.29");
222 const initial = config("session", "0.0.30");
223 const live = config("live", "0.0.30");
106224
107 expect(225 expect(
108 resolveServerConfigValue(226 resolveServerConfigValue(
...@@ -114,6 +232,16 @@ describe("server state projection", () => {...@@ -114,6 +232,16 @@ describe("server state projection", () => {
114 initial,232 initial,
115 ),233 ),
116 ).toBe(initial);234 ).toBe(initial);
235 expect(
236 resolveServerConfigValue(
237 {
238 config: staleLive,
239 latestEvent: snapshotEvent(staleLive),
240 source: "live",
241 },
242 initial,
243 ),
244 ).toBe(initial);
117 expect(245 expect(
118 resolveServerConfigValue(246 resolveServerConfigValue(
119 {247 {
packages/client-runtime/src/state/server.ts+326-9
...@@ -3,13 +3,19 @@ import {...@@ -3,13 +3,19 @@ import {
3 type ServerConfig,3 type ServerConfig,
4 type ServerConfigStreamEvent,4 type ServerConfigStreamEvent,
5 type ServerLifecycleWelcomePayload,5 type ServerLifecycleWelcomePayload,
6 type ServerSelfUpdateProgressEvent,
7 type ServerSelfUpdateResult,
6 WS_METHODS,8 WS_METHODS,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
9import * as Option from "effect/Option";14import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";15import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
12import * as Result from "effect/Result";17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";20import * as SubscriptionRef from "effect/SubscriptionRef";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";21import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -19,14 +25,148 @@ import {...@@ -19,14 +25,148 @@ import {
19 createEnvironmentRpcCommand,25 createEnvironmentRpcCommand,
20 createEnvironmentRpcQueryAtomFamily,26 createEnvironmentRpcQueryAtomFamily,
21 createEnvironmentRpcSubscriptionAtomFamily,27 createEnvironmentRpcSubscriptionAtomFamily,
28 createRuntimeCommand,
29 scheduleAtomCommandEffect,
22} from "./runtime.ts";30} from "./runtime.ts";
23import type { EnvironmentRegistry } from "../connection/registry.ts";31import { EnvironmentRegistry } from "../connection/registry.ts";
24import { EnvironmentSupervisor } from "../connection/supervisor.ts";32import { EnvironmentSupervisor } from "../connection/supervisor.ts";
25import { safeErrorLogAttributes } from "../errors/safeLog.ts";33import { safeErrorLogAttributes } from "../errors/safeLog.ts";
26import { EnvironmentCacheStore } from "../platform/persistence.ts";34import { EnvironmentCacheStore } from "../platform/persistence.ts";
27import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";35import {
36 isRpcClientError,
37 request,
38 runStream,
39 subscribe,
40 type EnvironmentRpcInput,
41} from "../rpc/client.ts";
28import { followStreamInEnvironment } from "./runtime.ts";42import { followStreamInEnvironment } from "./runtime.ts";
2943
44export type ServerUpdateStage = "downloading" | "installing" | "resuming";
45
46export type ServerUpdateState =
47 | { readonly status: "idle" }
48 | {
49 readonly status: "running";
50 readonly stage: ServerUpdateStage;
51 readonly fromVersion: string;
52 readonly targetVersion: string;
53 }
54 | {
55 readonly status: "failed";
56 readonly stage: ServerUpdateStage;
57 readonly fromVersion: string;
58 readonly targetVersion: string;
59 readonly message: string;
60 };
61
62export interface ServerUpdateTarget {
63 readonly environmentId: EnvironmentId;
64 readonly input: EnvironmentRpcInput<typeof WS_METHODS.serverUpdateServer>;
65}
66
67const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" };
68const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
69 Atom.withLabel("environment-data:server:update-state:empty"),
70);
71const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) =>
72 Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
73 Atom.withLabel(`environment-data:server:update-state:${environmentId}`),
74 ),
75);
76
77export class ServerUpdateResumeTimeoutError extends Schema.TaggedErrorClass<ServerUpdateResumeTimeoutError>()(
78 "ServerUpdateResumeTimeoutError",
79 {
80 environmentId: Schema.String,
81 targetVersion: Schema.String,
82 },
83) {
84 override get message(): string {
85 return `The server did not resume on t3@${this.targetVersion}.`;
86 }
87}
88
89export class ServerUpdateProgressIncompleteError extends Schema.TaggedErrorClass<ServerUpdateProgressIncompleteError>()(
90 "ServerUpdateProgressIncompleteError",
91 {
92 targetVersion: Schema.String,
93 },
94) {
95 override get message(): string {
96 return `The t3@${this.targetVersion} update ended before the server accepted the restart.`;
97 }
98}
99
100export function serverUpdateStateForProgressEvent(
101 fromVersion: string,
102 targetVersion: string,
103 event: ServerSelfUpdateProgressEvent,
104): Extract<ServerUpdateState, { status: "running" }> {
105 return {
106 status: "running",
107 stage: event.type === "complete" ? "resuming" : event.stage,
108 fromVersion,
109 targetVersion,
110 };
111}
112
113export function serverUpdateStateForServerVersion(
114 state: ServerUpdateState,
115 serverVersion: string | null,
116): ServerUpdateState {
117 return state.status === "idle" ||
118 state.status === "running" ||
119 serverVersion === null ||
120 state.fromVersion === serverVersion
121 ? state
122 : IDLE_SERVER_UPDATE_STATE;
123}
124
125function serverUpdateFailureMessage(error: unknown): string {
126 return error instanceof Error ? error.message : "Server update failed.";
127}
128
129function isRpcSocketError(error: unknown): boolean {
130 if (!isRpcClientError(error)) {
131 return false;
132 }
133 switch (error.reason._tag) {
134 case "SocketReadError":
135 case "SocketWriteError":
136 case "SocketCloseError":
137 return true;
138 default:
139 return false;
140 }
141}
142
143export function isLegacyUpdateHandoffLoss(cause: Cause.Cause<unknown>): boolean {
144 if (Cause.hasInterruptsOnly(cause)) {
145 return true;
146 }
147 return (
148 cause.reasons.length > 0 &&
149 cause.reasons.every((reason) => Cause.isFailReason(reason) && isRpcSocketError(reason.error))
150 );
151}
152
153export function resolveServerUpdateProgressResult<E>(
154 targetVersion: string,
155 terminal: Option.Option<ServerSelfUpdateResult>,
156 streamExit: Exit.Exit<void, E>,
157): Effect.Effect<ServerSelfUpdateResult, E | ServerUpdateProgressIncompleteError> {
158 if (
159 Option.isSome(terminal) &&
160 (Exit.isSuccess(streamExit) || isLegacyUpdateHandoffLoss(streamExit.cause))
161 ) {
162 return Effect.succeed(terminal.value);
163 }
164 if (Exit.isFailure(streamExit)) {
165 return Effect.failCause(streamExit.cause);
166 }
167 return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion }));
168}
169
30export interface ServerConfigProjection {170export interface ServerConfigProjection {
31 readonly config: ServerConfig;171 readonly config: ServerConfig;
32 readonly latestEvent: ServerConfigStreamEvent;172 readonly latestEvent: ServerConfigStreamEvent;
...@@ -225,7 +365,13 @@ export function resolveServerConfigValue(...@@ -225,7 +365,13 @@ export function resolveServerConfigValue(
225 projection: ServerConfigProjection | null,365 projection: ServerConfigProjection | null,
226 initialConfig: ServerConfig | null,366 initialConfig: ServerConfig | null,
227): ServerConfig | null {367): ServerConfig | null {
228 if (projection?.source === "live") return projection.config;368 if (
369 projection?.source === "live" &&
370 (initialConfig === null ||
371 projection.config.environment.serverVersion === initialConfig.environment.serverVersion)
372 ) {
373 return projection.config;
374 }
229 return initialConfig ?? projection?.config ?? null;375 return initialConfig ?? projection?.config ?? null;
230}376}
231377
...@@ -238,6 +384,8 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -238,6 +384,8 @@ export function createServerEnvironmentAtoms<R, E>(
238 },384 },
239) {385) {
240 const configScheduler = createAtomCommandScheduler();386 const configScheduler = createAtomCommandScheduler();
387 // Updates stay serial end-to-end, but only their handoff phase occupies the config lane.
388 const updateScheduler = createAtomCommandScheduler();
241 const configConcurrency = {389 const configConcurrency = {
242 mode: "serial" as const,390 mode: "serial" as const,
243 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,391 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
...@@ -271,6 +419,179 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -271,6 +419,179 @@ export function createServerEnvironmentAtoms<R, E>(
271 );419 );
272 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));420 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
273 });421 });
422 const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
423 Atom.make((get) =>
424 serverUpdateStateForServerVersion(
425 get(serverUpdateStateAtom(environmentId)),
426 get(configValueAtom(environmentId))?.environment.serverVersion ?? null,
427 ),
428 ).pipe(Atom.withLabel(`environment-data:server:update-state-value:${environmentId}`)),
429 );
430 const updateStateAtom = (environmentId: EnvironmentId | null) =>
431 environmentId === null ? EMPTY_SERVER_UPDATE_STATE_ATOM : updateStateValueAtom(environmentId);
432 const updateServer = createRuntimeCommand<
433 EnvironmentRegistry | EnvironmentCacheStore | R,
434 E,
435 ServerUpdateTarget,
436 ServerSelfUpdateResult,
437 unknown
438 >(runtime, {
439 label: "environment-data:server:update-server",
440 scheduler: updateScheduler,
441 concurrency: configConcurrency,
442 execute: (target, atomRegistry) => {
443 const stateAtom = serverUpdateStateAtom(target.environmentId);
444 const targetVersion = target.input.targetVersion;
445 let fromVersion =
446 atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ??
447 targetVersion;
448 let currentStage: ServerUpdateStage = "downloading";
449 atomRegistry.set(stateAtom, {
450 status: "running",
451 stage: currentStage,
452 fromVersion,
453 targetVersion,
454 });
455
456 return Effect.gen(function* () {
457 const environmentRegistry = yield* EnvironmentRegistry;
458 const result = yield* scheduleAtomCommandEffect(
459 atomRegistry,
460 configScheduler,
461 configConcurrency,
462 target,
463 Effect.gen(function* () {
464 const currentConfig = atomRegistry.get(configValueAtom(target.environmentId));
465 fromVersion = currentConfig?.environment.serverVersion ?? targetVersion;
466 atomRegistry.set(stateAtom, {
467 status: "running",
468 stage: currentStage,
469 fromVersion,
470 targetVersion,
471 });
472
473 const supportsProgress =
474 currentConfig?.environment.capabilities.serverSelfUpdateProgress === true;
475 const updateResult: ServerSelfUpdateResult = supportsProgress
476 ? yield* Effect.gen(function* () {
477 const terminal = yield* Ref.make<Option.Option<ServerSelfUpdateResult>>(
478 Option.none(),
479 );
480 const streamExit = yield* environmentRegistry
481 .runStream(
482 target.environmentId,
483 runStream(WS_METHODS.serverUpdateServerWithProgress, target.input),
484 )
485 .pipe(
486 Stream.runForEach((event) =>
487 Effect.sync(() => {
488 currentStage = event.type === "complete" ? "resuming" : event.stage;
489 atomRegistry.set(
490 stateAtom,
491 serverUpdateStateForProgressEvent(fromVersion, targetVersion, event),
492 );
493 }).pipe(
494 Effect.andThen(
495 event.type === "complete"
496 ? Ref.set(terminal, Option.some(event.result))
497 : Effect.void,
498 ),
499 ),
500 ),
501 Effect.exit,
502 );
503 return yield* resolveServerUpdateProgressResult(
504 targetVersion,
505 yield* Ref.get(terminal),
506 streamExit,
507 );
508 })
509 : yield* Effect.gen(function* () {
510 const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate;
511 const exit = yield* environmentRegistry
512 .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input))
513 .pipe(Effect.exit);
514 if (Exit.isSuccess(exit)) {
515 return exit.value;
516 }
517 if (
518 (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") &&
519 isLegacyUpdateHandoffLoss(exit.cause)
520 ) {
521 // Older servers can tear down the transport before their
522 // unary acknowledgement arrives. Treat only that transport
523 // loss as a handoff, then prove it by waiting for target ready.
524 return { targetVersion, method: selfUpdateMethod };
525 }
526 return yield* Effect.failCause(exit.cause);
527 });
528
529 currentStage = "resuming";
530 atomRegistry.set(stateAtom, {
531 status: "running",
532 stage: currentStage,
533 fromVersion,
534 targetVersion,
535 });
536 return updateResult;
537 }),
538 );
539
540 // The update restart is intentional. As soon as the supervisor sees
541 // that first failed connection, discard any prior backoff debt and
542 // retry immediately instead of carrying an old 16-second delay.
543 yield* environmentRegistry.stateChanges(target.environmentId).pipe(
544 Stream.filter((state) => state.phase === "backoff"),
545 Stream.take(1),
546 Stream.runDrain,
547 Effect.andThen(environmentRegistry.retryNow(target.environmentId)),
548 Effect.timeoutOption(Duration.seconds(30)),
549 Effect.ignore,
550 Effect.forkChild,
551 );
552
553 const resumed = yield* environmentRegistry
554 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))
555 .pipe(
556 Stream.filter(
557 (event) =>
558 event.type === "ready" && event.payload.environment.serverVersion === targetVersion,
559 ),
560 Stream.runHead,
561 Effect.timeoutOption(Duration.seconds(120)),
562 Effect.map(Option.flatten),
563 );
564 if (Option.isNone(resumed)) {
565 return yield* new ServerUpdateResumeTimeoutError({
566 environmentId: target.environmentId,
567 targetVersion,
568 });
569 }
570
571 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
572 return result;
573 }).pipe(
574 Effect.onExit((exit) =>
575 Effect.sync(() => {
576 if (Exit.isSuccess(exit)) {
577 return;
578 }
579 if (Cause.hasInterruptsOnly(exit.cause)) {
580 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
581 return;
582 }
583 atomRegistry.set(stateAtom, {
584 status: "failed",
585 stage: currentStage,
586 fromVersion,
587 targetVersion,
588 message: serverUpdateFailureMessage(Cause.squash(exit.cause)),
589 });
590 }),
591 ),
592 );
593 },
594 });
274 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>595 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>
275 Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe(596 Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe(
276 Atom.withLabel(`environment-data:server:settings:${environmentId}`),597 Atom.withLabel(`environment-data:server:settings:${environmentId}`),
...@@ -284,6 +605,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -284,6 +605,7 @@ export function createServerEnvironmentAtoms<R, E>(
284605
285 return {606 return {
286 configValueAtom,607 configValueAtom,
608 updateStateAtom,
287 settingsValueAtom,609 settingsValueAtom,
288 providersValueAtom,610 providersValueAtom,
289 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {611 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
...@@ -331,12 +653,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -331,12 +653,7 @@ export function createServerEnvironmentAtoms<R, E>(
331 scheduler: configScheduler,653 scheduler: configScheduler,
332 concurrency: configConcurrency,654 concurrency: configConcurrency,
333 }),655 }),
334 updateServer: createEnvironmentRpcCommand(runtime, {656 updateServer,
335 label: "environment-data:server:update-server",
336 tag: WS_METHODS.serverUpdateServer,
337 scheduler: configScheduler,
338 concurrency: configConcurrency,
339 }),
340 upsertKeybinding: createEnvironmentRpcCommand(runtime, {657 upsertKeybinding: createEnvironmentRpcCommand(runtime, {
341 label: "environment-data:server:upsert-keybinding",658 label: "environment-data:server:upsert-keybinding",
342 tag: WS_METHODS.serverUpsertKeybinding,659 tag: WS_METHODS.serverUpsertKeybinding,
packages/contracts/src/environment.ts+3
...@@ -54,6 +54,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -54,6 +54,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
54 servers that must be relaunched manually (dev checkouts, Windows54 servers that must be relaunched manually (dev checkouts, Windows
55 foreground runs, pre-update servers). */55 foreground runs, pre-update servers). */
56 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),56 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),
57 /** Server can stream self-update progress before acknowledging the
58 restart. Clients fall back to server.updateServer when absent. */
59 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),
57});60});
58export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;61export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
5962
packages/contracts/src/rpc.ts+13
...@@ -127,6 +127,7 @@ import {...@@ -127,6 +127,7 @@ import {
127 ServerProviderUpdatedPayload,127 ServerProviderUpdatedPayload,
128 ServerSelfUpdateError,128 ServerSelfUpdateError,
129 ServerSelfUpdateInput,129 ServerSelfUpdateInput,
130 ServerSelfUpdateProgressEvent,
130 ServerSelfUpdateResult,131 ServerSelfUpdateResult,
131 ServerTraceDiagnosticsResult,132 ServerTraceDiagnosticsResult,
132 ServerProcessDiagnosticsResult,133 ServerProcessDiagnosticsResult,
...@@ -218,6 +219,7 @@ export const WS_METHODS = {...@@ -218,6 +219,7 @@ export const WS_METHODS = {
218 serverRefreshProviders: "server.refreshProviders",219 serverRefreshProviders: "server.refreshProviders",
219 serverUpdateProvider: "server.updateProvider",220 serverUpdateProvider: "server.updateProvider",
220 serverUpdateServer: "server.updateServer",221 serverUpdateServer: "server.updateServer",
222 serverUpdateServerWithProgress: "server.updateServerWithProgress",
221 serverUpsertKeybinding: "server.upsertKeybinding",223 serverUpsertKeybinding: "server.upsertKeybinding",
222 serverRemoveKeybinding: "server.removeKeybinding",224 serverRemoveKeybinding: "server.removeKeybinding",
223 serverGetSettings: "server.getSettings",225 serverGetSettings: "server.getSettings",
...@@ -305,6 +307,16 @@ export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {...@@ -305,6 +307,16 @@ export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {
305 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),307 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
306});308});
307309
310export const WsServerUpdateServerWithProgressRpc = Rpc.make(
311 WS_METHODS.serverUpdateServerWithProgress,
312 {
313 payload: ServerSelfUpdateInput,
314 success: ServerSelfUpdateProgressEvent,
315 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
316 stream: true,
317 },
318);
319
308export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {320export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
309 payload: Schema.Struct({}),321 payload: Schema.Struct({}),
310 success: ServerSettings,322 success: ServerSettings,
...@@ -759,6 +771,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -759,6 +771,7 @@ export const WsRpcGroup = RpcGroup.make(
759 WsServerRefreshProvidersRpc,771 WsServerRefreshProvidersRpc,
760 WsServerUpdateProviderRpc,772 WsServerUpdateProviderRpc,
761 WsServerUpdateServerRpc,773 WsServerUpdateServerRpc,
774 WsServerUpdateServerWithProgressRpc,
762 WsServerUpsertKeybindingRpc,775 WsServerUpsertKeybindingRpc,
763 WsServerRemoveKeybindingRpc,776 WsServerRemoveKeybindingRpc,
764 WsServerGetSettingsRpc,777 WsServerGetSettingsRpc,
packages/contracts/src/server.ts+15
...@@ -592,6 +592,21 @@ export const ServerSelfUpdateResult = Schema.Struct({...@@ -592,6 +592,21 @@ export const ServerSelfUpdateResult = Schema.Struct({
592});592});
593export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;593export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
594594
595export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]);
596export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type;
597
598export const ServerSelfUpdateProgressEvent = Schema.Union([
599 Schema.Struct({
600 type: Schema.Literal("progress"),
601 stage: ServerSelfUpdateProgressStage,
602 }),
603 Schema.Struct({
604 type: Schema.Literal("complete"),
605 result: ServerSelfUpdateResult,
606 }),
607]);
608export type ServerSelfUpdateProgressEvent = typeof ServerSelfUpdateProgressEvent.Type;
609
595export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()(610export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()(
596 "ServerSelfUpdateError",611 "ServerSelfUpdateError",
597 {612 {