| author | |
| committer | |
| log | 197d3487116372ca2f16661f57d4e869fc3cb6c0 |
| tree | eeb4130265705a341ac320f2b416df7bb2c42118 |
| parent | 5c9358acaa34f8619513bba1e6821f0fdec27f74 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 26 | 26 | ||
| 27 | const NODE_PATH = "/usr/local/bin/node"; | 27 | const NODE_PATH = "/usr/local/bin/node"; |
| 28 | 28 | ||
| 29 | const 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 | |||
| 46 | const 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 | |||
| 29 | interface RecordedCommand { | 56 | interface 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); |
| 463 | 494 | ||
| 464 | const concurrentError = yield* context.service | 495 | 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"]); |
| 512 | 543 | ||
| 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); |
| 544 | 577 | ||
| 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"; |
| 11 | import { | 12 | import { |
| ... | @@ -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 |
| 227 | 229 | ||
| 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 | } |
| 251 | 253 | ||
| 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 | ); |
| 262 | 265 | ||
| 266 | yield* reportProgress("installing"); | ||
| 263 | // A broken artifact (failed native build, incompatible node) must be | 267 | // 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* runner | 269 | 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 is | 356 | // 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* runner | 361 | 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 | }; |
| 149 | 152 |
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"; |
| 298 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 298 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 299 | import { ServerUpdateAction } from "./ServerUpdateAction"; | 299 | import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; |
| 300 | import { | 300 | import { |
| 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 && activeThread | 1876 | 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 action | 1953 | // 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 | <ServerUpdateAction | 1959 | <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 @@ |
| 1 | import type { Dispatch, ReactElement, SetStateAction } from "react"; | 1 | import type { ReactElement } from "react"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 5 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | 6 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 7 | ||
| 7 | const testState = vi.hoisted(() => ({ | 8 | const testState = vi.hoisted(() => ({ |
| 8 | updateServer: vi.fn(), | 9 | updateServer: vi.fn(), |
| 9 | toast: vi.fn(), | 10 | toast: vi.fn(), |
| 10 | })); | 11 | })); |
| 11 | 12 | ||
| 12 | const 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 | |||
| 58 | vi.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 | |||
| 68 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); | ||
| 69 | vi.mock("~/hooks/useCopyToClipboard", () => ({ | 13 | vi.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 | })); |
| 81 | 25 | ||
| 82 | import { ServerUpdateAction } from "./ServerUpdateAction"; | 26 | import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; |
| 83 | 27 | ||
| 84 | type ActionElement = ReactElement<{ | 28 | type ActionElement = ReactElement<{ |
| 85 | readonly disabled?: boolean; | ||
| 86 | readonly onClick?: () => void; | 29 | readonly onClick?: () => void; |
| 87 | }>; | 30 | }>; |
| 88 | 31 | ||
| 89 | function renderAction(): ActionElement { | 32 | function 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 | } |
| 98 | 40 | ||
| 99 | function 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 | |||
| 107 | async function flushPromises(): Promise<void> { | 41 | async 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 | } |
| 114 | 45 | ||
| 115 | describe("ServerUpdateAction", () => { | 46 | describe("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 | }); |
| 122 | 51 | ||
| 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 }), |
| 126 | 55 | ); | |
| 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); | ||
| 133 | 56 | ||
| 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(); |
| 145 | 59 | ||
| 146 | // The click-based deadline would have fired by now. Success gets a fresh | 60 | 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", |
| 153 | 67 | 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 | }); |
| 160 | 70 | ||
| 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( |
| 168 | 78 | 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(); | ||
| 178 | 82 | ||
| 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?.(); | ||
| 181 | 86 | ||
| 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 | }); |
| 186 | 92 | ||
| 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())); |
| 189 | 95 | ||
| 190 | renderAction().props.onClick?.(); | 96 | renderAction().props.onClick?.(); |
| 191 | await flushPromises(); | 97 | await flushPromises(); |
| 192 | 98 | ||
| 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 | |||
| 103 | describe("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 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | ||
| 2 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; |
| 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | ||
| 3 | import { | 3 | import { |
| 4 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { CheckIcon } from "lucide-react"; | ||
| 7 | 8 | ||
| 8 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 9 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 10 | import { cn } from "~/lib/utils"; | ||
| 9 | import { serverEnvironment } from "~/state/server"; | 11 | import { serverEnvironment } from "~/state/server"; |
| 10 | import { useAtomCommand } from "~/state/use-atom-command"; | 12 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 11 | import { manualServerUpdateCommand } from "~/versionSkew"; | 13 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 12 | import { Button } from "./ui/button"; | 14 | import { Button } from "./ui/button"; |
| 13 | import { Spinner } from "./ui/spinner"; | ||
| 14 | import { toastManager } from "./ui/toast"; | 15 | import { toastManager } from "./ui/toast"; |
| 15 | 16 | ||
| 16 | /** | 17 | const UPDATE_STEPS = [ |
| 17 | * The npm install on the server side is capped at 10 minutes; expire the | 18 | { stage: "downloading", label: "Download" }, |
| 18 | * spinner a bit beyond that so a dead transport never strands a disabled | 19 | { stage: "installing", label: "Install" }, |
| 19 | * button, while a legitimately slow install is never cut off. | 20 | { stage: "resuming", label: "Resume" }, |
| 20 | */ | 21 | ] as const; |
| 21 | const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; | 22 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); |
| 22 | 23 | ||
| 23 | function updateFailureMessage(error: unknown): string { | 24 | function 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 | } |
| 26 | 27 | ||
| 28 | function 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 | |||
| 45 | export 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 path | 125 | * 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 update | 126 | * delegate their full lifecycle to client-runtime so this component can |
| 30 | * themselves, an update-the-desktop-app hint for desktop-managed backends | 127 | * 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 | */ |
| 35 | export function ServerUpdateAction({ | 129 | export 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 | }); |
| 70 | 162 | ||
| 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. Keep | 184 | toastManager.add({ |
| 163 | // the action disabled until version sync unmounts it, or until the | 185 | 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 | }; |
| 173 | 193 | ||
| 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 | } |
| 190 | 210 | ||
| 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 server | 213 | {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"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | ||
| 10 | import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; | 11 | import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; |
| 11 | import { | 12 | import { |
| 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"; |
| 133 | import { useAtomCommand } from "../../state/use-atom-command"; | 134 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 135 | import { serverEnvironment } from "~/state/server"; | ||
| 134 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | 136 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 135 | import { ServerUpdateAction } from "../ServerUpdateAction"; | 137 | import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; |
| 136 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | 138 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 137 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; | 139 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 138 | 140 | ||
| ... | @@ -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 | <ServerUpdateAction | 1466 | <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 | <SettingsRow | 3002 | <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 reconnects | 3014 | 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 | <ServerUpdateAction | 3030 | <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 | ) : undefined | 3039 | ) : 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 Presentation | 13 | ## Detection and Presentation |
| 14 | 14 | ||
| 15 | `ExecutionEnvironmentDescriptor` includes the server version and an optional | 15 | `ExecutionEnvironmentDescriptor` includes the server version and an optional |
| 16 | `capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after | 16 | `capabilities.serverSelfUpdate` value. Progress-capable servers also advertise |
| 17 | loading server config. | 17 | `capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION` |
| 18 | after loading server config. | ||
| 18 | 19 | ||
| 19 | The optional capability is intentionally backward compatible. An older server does not know about | 20 | The optional capability is intentionally backward compatible. An older server does not know about |
| 20 | the field, so a missing value means the client must offer a manual relaunch instead of sending an | 21 | the 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 |
| 28 | Both surfaces target the client's exact version. When the reconnected server reports that version, | 29 | Both surfaces target the client's exact version. When the reconnected server reports that version, |
| 29 | the mismatch and action disappear. | 30 | the mismatch and action disappear. |
| 30 | 31 | ||
| 32 | The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read | ||
| 33 | the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the | ||
| 34 | operation. | ||
| 35 | |||
| 31 | ## Capability Selection | 36 | ## Capability Selection |
| 32 | 37 | ||
| 33 | The server resolves its capability once at startup and publishes it in the environment descriptor. | 38 | The 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 --> L | 67 | 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 | ``` |
| 64 | 73 | ||
| 65 | `server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its | 74 | Both update RPCs require the environment's `orchestration:operate` authorization scope. Their |
| 66 | payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as | 75 | payload 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 |
| 77 | new client can still repair skew with an older server. | ||
| 68 | 78 | ||
| 69 | The update service permits one update at a time. It installs `t3@<version>` under | 79 | The 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 exits | 80 | `<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 Handoff | 99 | ## Process Handoff |
| 90 | 100 | ||
| 91 | For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified | 101 | For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified |
| 92 | runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous | 102 | runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same |
| 93 | unit before returning an error. | 103 | short grace period used by foreground respawn. A rejected deferred restart restores the previous |
| 104 | unit and is logged by the still-running process. | ||
| 94 | 105 | ||
| 95 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI | 106 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI |
| 96 | arguments. It then acknowledges the request and schedules the current process to exit. The delays | 107 | arguments. It then acknowledges the request and schedules the current process to exit. The delays |
| 97 | give the acknowledgement time to cross direct or relayed connections before the socket closes. | 108 | give the acknowledgement time to cross direct or relayed connections before the socket closes. |
| 98 | 109 | ||
| 99 | There is no separate progress stream. The update request remains pending while npm installs and the | 110 | Progress-capable servers emit `downloading` before installing the pinned runtime and `installing` |
| 100 | client shows a disabled update action. A restart can interrupt the request normally; the connection | 111 | before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The |
| 101 | runtime keeps the environment registered and reconnects through its usual retry path. After an | 112 | client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with |
| 102 | acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a | 113 | the target version, and only then completes the operation. It watches for the intentional |
| 103 | safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, | 114 | disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt |
| 104 | the UI releases the interrupted action without reporting a false update failure and lets reconnect | 115 | without adding a separate reconnect loop. |
| 105 | and the next version check determine the result. | ||
| 106 | 116 | ||
| 107 | ## Release Invariant | 117 | ## Release Invariant |
| 108 | 118 |
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. |
| 31 | The available action depends on how that server was started. T3 Code does not update connected | 31 | The available action depends on how that server was started. T3 Code does not update connected |
| 32 | servers silently in the background. | 32 | servers silently in the background. |
| 33 | 33 | ||
| 34 | After 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 | ||
| 36 | Connections, so navigating between them does not lose the update. A failed step remains visible | ||
| 37 | with its error and an option to retry. | ||
| 38 | |||
| 34 | If the server uses the T3 Code background service, you can also update it directly on the host: | 39 | If the server uses the T3 Code background service, you can also update it directly on the host: |
| 35 | 40 | ||
| 36 | ```sh | 41 | ```sh |
| ... | @@ -42,11 +47,11 @@ commands. | ... | @@ -42,11 +47,11 @@ commands. |
| 42 | 47 | ||
| 43 | ## After the Update | 48 | ## After the Update |
| 44 | 49 | ||
| 45 | Keep the web or desktop app open while the server restarts. When it reconnects with the matching | 50 | Keep the web or desktop app open while the server restarts. The update completes only after the |
| 46 | version, the warning and update action disappear. | 51 | replacement server reports the requested version and is ready to accept commands. The warning and |
| 52 | progress rail then disappear. | ||
| 47 | 53 | ||
| 48 | If the client reports a timeout, the server may still be finishing the update. Wait a minute, then | 54 | If a step fails: |
| 49 | reconnect or open **Settings** → **Connections** again. If the warning remains: | ||
| 50 | 55 | ||
| 51 | 1. Retry the offered action once. | 56 | 1. Retry the offered action once. |
| 52 | 2. Make sure you updated the machine named in the warning, not only the device you are using. | 57 | 2. 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 | ); |
| 515 | 515 | ||
| 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.desired | 236 | !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 | }; |
| 644 | 645 | ||
| 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 | ); |
| 765 | 771 | ||
| 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 | ); |
| 769 | 776 |
packages/client-runtime/src/rpc/client.ts+2-1| ... | @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = | ... | @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 56 | 56 | ||
| 57 | export type EnvironmentStreamCommandRpcTag = | 57 | export type EnvironmentStreamCommandRpcTag = |
| 58 | | typeof WS_METHODS.cloudInstallRelayClient | 58 | | typeof WS_METHODS.cloudInstallRelayClient |
| 59 | | typeof WS_METHODS.serverUpdateServerWithProgress | ||
| 59 | | typeof WS_METHODS.gitRunStackedAction; | 60 | | typeof WS_METHODS.gitRunStackedAction; |
| 60 | 61 | ||
| 61 | export type EnvironmentStreamRpcTag = | 62 | export 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 | }) {} |
| 82 | 83 | ||
| 83 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | 84 | export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); |
| 84 | 85 | ||
| 85 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; | 86 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; |
| 86 | 87 |
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 | }); |
| 401 | 402 | ||
| 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 | } |
| 255 | 255 | ||
| 256 | /** Runs one effect inside an existing command scheduler lane. */ | ||
| 257 | export 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 | |||
| 256 | export async function runAtomCommand<W, A, E>( | 278 | export 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"; |
| 8 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 11 | import * as Queue from "effect/Queue"; | 13 | import * as Queue from "effect/Queue"; |
| 12 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 16 | import { RpcClientError } from "effect/unstable/rpc"; | ||
| 17 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 14 | 18 | ||
| 15 | import { | 19 | import { |
| 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"; |
| 24 | import { | 28 | import { |
| 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"; |
| 30 | 38 | ||
| 31 | const CONFIG = { | 39 | const CONFIG = { |
| ... | @@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 62 | } | 70 | } |
| 63 | 71 | ||
| 64 | describe("server state projection", () => { | 72 | describe("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 | }); |
| 101 | 212 | ||
| 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"); | ||
| 106 | 224 | ||
| 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"; |
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 8 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | ||
| 9 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; | 15 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 12 | import * as Result from "effect/Result"; | 17 | import * as Result from "effect/Result"; |
| 18 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 20 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 21 | import { 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"; |
| 23 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 31 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 24 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 32 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 25 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | 33 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 26 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | 34 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 27 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | 35 | import { |
| 36 | isRpcClientError, | ||
| 37 | request, | ||
| 38 | runStream, | ||
| 39 | subscribe, | ||
| 40 | type EnvironmentRpcInput, | ||
| 41 | } from "../rpc/client.ts"; | ||
| 28 | import { followStreamInEnvironment } from "./runtime.ts"; | 42 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 29 | 43 | ||
| 44 | export type ServerUpdateStage = "downloading" | "installing" | "resuming"; | ||
| 45 | |||
| 46 | export 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 | |||
| 62 | export interface ServerUpdateTarget { | ||
| 63 | readonly environmentId: EnvironmentId; | ||
| 64 | readonly input: EnvironmentRpcInput<typeof WS_METHODS.serverUpdateServer>; | ||
| 65 | } | ||
| 66 | |||
| 67 | const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; | ||
| 68 | const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( | ||
| 69 | Atom.withLabel("environment-data:server:update-state:empty"), | ||
| 70 | ); | ||
| 71 | const 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 | |||
| 77 | export 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 | |||
| 89 | export 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 | |||
| 100 | export 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 | |||
| 113 | export 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 | |||
| 125 | function serverUpdateFailureMessage(error: unknown): string { | ||
| 126 | return error instanceof Error ? error.message : "Server update failed."; | ||
| 127 | } | ||
| 128 | |||
| 129 | function 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 | |||
| 143 | export 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 | |||
| 153 | export 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 | |||
| 30 | export interface ServerConfigProjection { | 170 | export 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 | } |
| 231 | 377 | ||
| ... | @@ -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>( |
| 284 | 605 | ||
| 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, Windows | 54 | 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 | }); |
| 58 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; | 61 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 59 | 62 |
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 | }); |
| 307 | 309 | ||
| 310 | export 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 | |||
| 308 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | 320 | export 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 | }); |
| 593 | export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; | 593 | export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; |
| 594 | 594 | ||
| 595 | export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]); | ||
| 596 | export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type; | ||
| 597 | |||
| 598 | export 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 | ]); | ||
| 608 | export type ServerSelfUpdateProgressEvent = typeof ServerSelfUpdateProgressEvent.Type; | ||
| 609 | |||
| 595 | export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()( | 610 | export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()( |
| 596 | "ServerSelfUpdateError", | 611 | "ServerSelfUpdateError", |
| 597 | { | 612 | { |