History

fix: address automatic update review findings

t3-code[bot] committed 2 months ago

Commit 4eac7f41 parent 9a447b810 files · +162 −109Browse files

Unified · Split

10 files
  1. apps/server/src/bin.test.ts +3 −3
  2. apps/server/src/cloud/selfUpdate.test.ts +22 −21
  3. apps/server/src/cloud/selfUpdate.ts +49 −9
  4. apps/server/src/orchestration/http.ts +2 −2
  5. apps/server/src/ws.ts +9 −35
  6. apps/web/src/components/AutomaticServerUpdateCoordinator.tsx +9 −1
  7. apps/web/src/components/ChatView.tsx +6 −6
  8. apps/web/src/components/settings/ConnectionsSettings.tsx +47 −31
  9. packages/client-runtime/src/state/server.test.ts +6 −0
  10. packages/client-runtime/src/state/server.ts +9 −1
apps/server/src/bin.test.ts+3−3
@@ -41,12 +41,12 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; · 40 unmodified lines
4141 import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
4242 import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
4343 import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
44−import { ServerSelfUpdate } from "./cloud/selfUpdate.ts";
44+import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
4545
4646 const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
4747 const ServerSelfUpdatePassThroughLayer = Layer.succeed(
48− ServerSelfUpdate,
49− ServerSelfUpdate.of({
48+ ServerSelfUpdate.ServerSelfUpdate,
49+ ServerSelfUpdate.ServerSelfUpdate.of({
5050 update: () => Effect.die(new Error("server update is unused in this test")),
5151 withCommandAdmission: (effect) => effect,
5252 }),
apps/server/src/cloud/selfUpdate.test.ts+22−21
@@ -19,6 +19,7 @@ interface HarnessOptions { · 18 unmodified lines
1919 readonly mode?: "web" | "desktop";
2020 readonly managed?: boolean;
2121 readonly preflight?: "ready" | "blocked";
22+ readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>;
2223 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
2324 }
2425
@@ -90,6 +91,12 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( · 65 unmodified lines
9091 Effect.provideService(ProcessRunner.ProcessRunner, runner),
9192 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
9293 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
94+ Effect.provideService(
95+ ServerSelfUpdate.ServerSelfUpdateIdleCheck,
96+ ServerSelfUpdate.ServerSelfUpdateIdleCheck.of({
97+ assertIdle: options.automaticUpdateIdle?.(order) ?? Effect.void,
98+ }),
99+ ),
93100 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
94101 );
95102 return { selfUpdate, order };
@@ -135,7 +142,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 39 unmodified lines
135142 Effect.gen(function* () {
136143 const { selfUpdate, order } = yield* makeHarness();
137144 const error = yield* selfUpdate
138− .update({ targetVersion: "0.0.1", automatic: true }, undefined, () => Effect.void)
145+ .update({ targetVersion: "0.0.1", automatic: true })
139146 .pipe(Effect.flip);
140147
141148 expect(error.reason).toContain("newer version");
@@ -159,28 +166,24 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 17 unmodified lines
159166 ),
160167 });
161168
162− yield* selfUpdate
163− .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)
164− .pipe(Effect.flip);
165− yield* selfUpdate
166− .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)
167− .pipe(Effect.flip);
169+ yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
170+ yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
171+ expect(requests).toBe(1);
172+
173+ yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip);
174+ expect(requests).toBe(1);
175+ yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
168176 expect(requests).toBe(1);
169177
170178 yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip);
171179 expect(requests).toBe(2);
172− yield* selfUpdate
173− .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)
174− .pipe(Effect.flip);
175− expect(requests).toBe(2);
176180 }),
177181 );
178182
179183 it.effect("rechecks automatic updates after staging and before activation", () =>
180184 Effect.gen(function* () {
181− const { selfUpdate, order } = yield* makeHarness();
182− const error = yield* selfUpdate
183− .update({ targetVersion: "1.1.0", automatic: true }, undefined, () =>
185+ const { selfUpdate, order } = yield* makeHarness({
186+ automaticUpdateIdle: (order) =>
184187 Effect.sync(() => order.push("idle-check")).pipe(
185188 Effect.andThen(
186189 Effect.fail(
@@ -190,7 +193,9 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 3 unmodified lines
190193 ),
191194 ),
192195 ),
193− )
196+ });
197+ const error = yield* selfUpdate
198+ .update({ targetVersion: "1.1.0", automatic: true })
194199 .pipe(Effect.flip);
195200
196201 expect(error.reason).toContain("new work started");
@@ -210,7 +215,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 13 unmodified lines
210215 ),
211216 });
212217 const updateFiber = yield* selfUpdate
213− .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)
218+ .update({ targetVersion: "1.1.0", automatic: true })
214219 .pipe(Effect.forkChild);
215220 yield* Deferred.await(requestStarted);
216221 const interruptFiber = yield* Fiber.interrupt(updateFiber).pipe(Effect.forkChild);
@@ -234,11 +239,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 17 unmodified lines
234239 it.effect("blocks new commands after an automatic handoff is accepted", () =>
235240 Effect.gen(function* () {
236241 const { selfUpdate } = yield* makeHarness();
237− yield* selfUpdate.update(
238− { targetVersion: "1.1.0", automatic: true },
239− undefined,
240− () => Effect.void,
241− );
242+ yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true });
242243
243244 const error = yield* selfUpdate
244245 .withCommandAdmission(Effect.succeed("started"))
apps/server/src/cloud/selfUpdate.ts+49−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 type ServerSelfUpdateResult,
77 } from "@t3tools/contracts";
88 import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
9+import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";
910 import { compareSemverVersions } from "@t3tools/shared/semver";
1011 import * as Context from "effect/Context";
1112 import * as Duration from "effect/Duration";
@@ -18,6 +19,7 @@ import * as Semaphore from "effect/Semaphore"; · 6 unmodified lines
1819
1920 import * as ServerConfig from "../config.ts";
2021 import * as ProcessRunner from "../processRunner.ts";
22+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
2123 import {
2224 ensurePinnedRuntimeInstalled,
2325 PinnedRuntimeInstallError,
@@ -44,7 +46,6 @@ export class ServerSelfUpdate extends Context.Service< · 20 unmodified lines
4446 readonly update: (
4547 input: ServerSelfUpdateInput,
4648 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
47− confirmAutomaticUpdateIdle?: () => Effect.Effect<void, ServerSelfUpdateError>,
4849 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
4950 readonly withCommandAdmission: <A, E, R>(
5051 effect: Effect.Effect<A, E, R>,
@@ -52,6 +53,38 @@ export class ServerSelfUpdate extends Context.Service< · 1 unmodified line
5253 }
5354 >()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
5455
56+export class ServerSelfUpdateIdleCheck extends Context.Service<
57+ ServerSelfUpdateIdleCheck,
58+ { readonly assertIdle: Effect.Effect<void, ServerSelfUpdateError> }
59+>()("t3/cloud/selfUpdate/ServerSelfUpdateIdleCheck") {}
60+
61+const idleCheckLayer = Layer.effect(
62+ ServerSelfUpdateIdleCheck,
63+ Effect.gen(function* () {
64+ const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
65+ return ServerSelfUpdateIdleCheck.of({
66+ assertIdle: projections.getShellSnapshot().pipe(
67+ Effect.mapError(
68+ (cause) =>
69+ new ServerSelfUpdateError({
70+ reason: "Automatic update could not verify that this server is idle.",
71+ cause,
72+ }),
73+ ),
74+ Effect.flatMap((snapshot) =>
75+ hasAutomaticServerUpdateActiveWork(snapshot.threads)
76+ ? Effect.fail(
77+ new ServerSelfUpdateError({
78+ reason: "Automatic update paused because new work started while downloading.",
79+ }),
80+ )
81+ : Effect.void,
82+ ),
83+ ),
84+ });
85+ }),
86+);
87+
5588 export const make = Effect.fn("cloud.server_self_update.make")(function* () {
5689 const serverConfig = yield* ServerConfig.ServerConfig;
5790 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
@@ -59,6 +92,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 1 unmodified line
5992 const fs = yield* FileSystem.FileSystem;
6093 const path = yield* Path.Path;
6194 const execPath = yield* HostProcessExecutablePath;
95+ const idleCheck = yield* ServerSelfUpdateIdleCheck;
6296 const inFlight = yield* Ref.make(false);
6397 const lastFailedTarget = yield* Ref.make<string | null>(null);
6498 const automaticHandoffPending = yield* Ref.make(false);
@@ -89,7 +123,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 24 unmodified lines
89123
90124 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
91125 "cloud.server_self_update.update",
92− )(function* (input, reportProgress = () => Effect.void, confirmAutomaticUpdateIdle) {
126+ )(function* (input, reportProgress = () => Effect.void) {
93127 if (capability === "desktop-managed") {
94128 return yield* failWith(
95129 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
@@ -111,7 +145,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 15 unmodified lines
111145 ) {
112146 return yield* failWith("Automatic updates must move the server to a newer version.");
113147 }
114− if (input.automatic === true && (yield* Ref.get(lastFailedTarget)) === targetVersion) {
148+ const previousFailedTarget = yield* Ref.get(lastFailedTarget);
149+ if (input.automatic === true && previousFailedTarget === targetVersion) {
115150 return yield* failWith(
116151 "An automatic update to this version already failed. Retry it manually before automatic updates resume.",
117152 );
@@ -119,7 +154,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 1 unmodified line
119154 if (yield* Ref.getAndSet(inFlight, true)) {
120155 return yield* failWith("A server update is already in progress.");
121156 }
122− if (input.automatic !== true) {
157+ if (input.automatic !== true && previousFailedTarget === targetVersion) {
123158 yield* Ref.set(lastFailedTarget, null);
124159 }
125160
@@ -211,10 +246,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 85 unmodified lines
211246 if (input.automatic === true) {
212247 yield* commandAdmission.withPermits(1)(
213248 Effect.gen(function* () {
214− if (confirmAutomaticUpdateIdle === undefined) {
215− return yield* failWith("Automatic update could not verify that this server is idle.");
216− }
217− yield* confirmAutomaticUpdateIdle();
249+ yield* idleCheck.assertIdle;
218250 yield* Ref.set(automaticHandoffPending, true);
219251 }),
220252 );
@@ -249,7 +281,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 28 unmodified lines
249281 [
250282 Ref.set(inFlight, false),
251283 Ref.set(automaticHandoffPending, false),
252− Ref.set(lastFailedTarget, targetVersion),
284+ Ref.set(
285+ lastFailedTarget,
286+ input.automatic === true ||
287+ previousFailedTarget === null ||
288+ previousFailedTarget === targetVersion
289+ ? targetVersion
290+ : previousFailedTarget,
291+ ),
253292 ],
254293 { discard: true },
255294 ),
@@ -263,4 +302,5 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 7 unmodified lines
263302
264303 export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
265304 Layer.provide(ProcessRunner.layer),
305+ Layer.provide(idleCheckLayer),
266306 );
apps/server/src/orchestration/http.ts+2−2
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 } from "../auth/http.ts";
1919 import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
2020 import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
21−import { ServerSelfUpdate } from "../cloud/selfUpdate.ts";
21+import * as ServerSelfUpdate from "../cloud/selfUpdate.ts";
2222
2323 export const orchestrationHttpApiLayer = HttpApiBuilder.group(
2424 EnvironmentHttpApi,
@@ -26,7 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 1 unmodified line
2626 Effect.fnUntraced(function* (handlers) {
2727 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2828 const orchestrationEngine = yield* OrchestrationEngineService;
29− const serverSelfUpdate = yield* ServerSelfUpdate;
29+ const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
3030
3131 return handlers
3232 .handle(
apps/server/src/ws.ts+9−35
@@ -59,7 +59,7 @@ import { · 58 unmodified lines
5959 WsRpcGroup,
6060 } from "@t3tools/contracts";
6161 import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
62−import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";
62+
6363 import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
6464 import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
6565
@@ -489,25 +489,6 @@ const makeWsRpcLayer = ( · 423 unmodified lines
489489 const serverEventId = randomUUID.pipe(Effect.map(EventId.make));
490490 const serverCommandId = (tag: string) =>
491491 randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
492− const confirmAutomaticUpdateIdle = () =>
493− projectionSnapshotQuery.getShellSnapshot().pipe(
494− Effect.mapError(
495− (cause) =>
496− new ServerSelfUpdateError({
497− reason: "Automatic update could not verify that this server is idle.",
498− cause,
499− }),
500− ),
501− Effect.flatMap((snapshot) =>
502− hasAutomaticServerUpdateActiveWork(snapshot.threads)
503− ? Effect.fail(
504− new ServerSelfUpdateError({
505− reason: "Automatic update paused because new work started while downloading.",
506− }),
507− )
508− : Effect.void,
509− ),
510− );
511492
512493 const loadAuthAccessSnapshot = () =>
513494 Effect.all({
@@ -1487,26 +1468,19 @@ const makeWsRpcLayer = ( · 973 unmodified lines
14871468 },
14881469 ),
14891470 [WS_METHODS.serverUpdateServer]: (input) =>
1490− observeRpcEffect(
1491− WS_METHODS.serverUpdateServer,
1492− serverSelfUpdate.update(input, undefined, confirmAutomaticUpdateIdle),
1493− {
1494− "rpc.aggregate": "server",
1495− },
1496− ),
1471+ observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
1472+ "rpc.aggregate": "server",
1473+ }),
14971474 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>
14981475 observeRpcStream(
14991476 WS_METHODS.serverUpdateServerWithProgress,
15001477 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
15011478 serverSelfUpdate
1502− .update(
1503− input,
1504− (stage) =>
1505− Queue.offer(queue, {
1506− type: "progress",
1507− stage,
1508− }).pipe(Effect.asVoid),
1509− confirmAutomaticUpdateIdle,
1479+ .update(input, (stage) =>
1480+ Queue.offer(queue, {
1481+ type: "progress",
1482+ stage,
1483+ }).pipe(Effect.asVoid),
15101484 )
15111485 .pipe(
15121486 Effect.flatMap((result) =>
apps/web/src/components/AutomaticServerUpdateCoordinator.tsx+9−1
@@ -73,7 +73,15 @@ function EnvironmentAutomaticServerUpdate({ · 72 unmodified lines
7373 }
7474
7575 const targetKey = `${fromVersion}->${targetVersion}`;
76− if (updateState.status === "running" || updateState.status === "failed") {
76+ if (updateState.status === "running") {
77+ attemptedTargetRef.current = targetKey;
78+ return;
79+ }
80+ if (
81+ updateState.status === "failed" &&
82+ updateState.fromVersion === fromVersion &&
83+ updateState.targetVersion === targetVersion
84+ ) {
7785 attemptedTargetRef.current = targetKey;
7886 return;
7987 }
apps/web/src/components/ChatView.tsx+6−6
@@ -2133,8 +2133,8 @@ function ChatViewContent(props: ChatViewProps) { · 2132 unmodified lines
21332133 (serverUpdateState.status !== "idle" ||
21342134 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
21352135 ) {
2136− const updateInProgress =
2137− serverUpdateState.status === "running" || serverUpdateState.status === "pending";
2136+ const updateInProgress = serverUpdateState.status === "running";
2137+ const updatePending = serverUpdateState.status === "pending";
21382138 const updateFailed = serverUpdateState.status === "failed";
21392139 items.push({
21402140 id: `server-version:${serverUpdateEnvironmentId}`,
@@ -2145,15 +2145,15 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines
21452145 // In-flight and failed states carry their own status dot inside
21462146 // ServerUpdateProgress; only the idle offer needs an icon.
21472147 icon:
2148− updateInProgress || updateFailed ? null : (
2148+ updateInProgress || updatePending || updateFailed ? null : (
21492149 <span
21502150 className="size-1.5 rounded-full border border-muted-foreground/40"
21512151 aria-hidden="true"
21522152 />
21532153 ),
21542154 title:
2155− updateInProgress || updateFailed ? (
2156− `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2155+ updateInProgress || updatePending || updateFailed ? (
2156+ `${updateFailed ? "Could not update" : updatePending ? "Automatic update waiting for" : "Updating"} ${versionMismatchServerLabel}`
21572157 ) : versionMismatch ? (
21582158 <Tooltip>
21592159 <TooltipTrigger
@@ -2172,7 +2172,7 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines
21722172 "Server update available"
21732173 ),
21742174 description:
2175− updateInProgress || updateFailed ? (
2175+ updateInProgress || updatePending || updateFailed ? (
21762176 <ServerUpdateProgress state={serverUpdateState} />
21772177 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
21782178 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
apps/web/src/components/settings/ConnectionsSettings.tsx+47−31
@@ -1529,11 +1529,13 @@ function SavedBackendListRow({ · 1528 unmodified lines
15291529 </TooltipPopup>
15301530 </Tooltip>
15311531 ) : null}
1532− <AutomaticServerUpdateControl
1533− environmentId={environmentId}
1534− serverConfig={environment.serverConfig}
1535− compact
1536− />
1532+ {environment.serverConfig !== null ? (
1533+ <AutomaticServerUpdateControl
1534+ environmentId={environmentId}
1535+ serverConfig={environment.serverConfig}
1536+ compact
1537+ />
1538+ ) : null}
15371539 {environment.connection.error && !resumingServerUpdate ? (
15381540 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
15391541 <span className="truncate">{connectionStatusText(environment.connection)}</span>
@@ -1818,6 +1820,15 @@ function CloudRemoteEnvironmentRows({ · 278 unmodified lines
18181820 export function ConnectionsSettings() {
18191821 const desktopBridge = window.desktopBridge;
18201822 const { environments } = useEnvironments();
1823+ const automaticUpdateEnvironments = useMemo(
1824+ () =>
1825+ environments.filter(
1826+ (environment) =>
1827+ environment.connection.phase === "connected" &&
1828+ resolveServerSelfUpdateCapability(environment.serverConfig) === "boot-service",
1829+ ),
1830+ [environments],
1831+ );
18211832 const primaryEnvironment = usePrimaryEnvironment();
18221833 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
18231834 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
@@ -2322,15 +2333,12 @@ export function ConnectionsSettings() { · 498 unmodified lines
23222333 ]);
23232334
23242335 const handleEnableAutomaticUpdates = async () => {
2325− const connectedEnvironments = environments.filter(
2326− (environment) => environment.connection.phase === "connected",
2327− );
2328− if (connectedEnvironments.length === 0) return;
2336+ if (automaticUpdateEnvironments.length === 0) return;
23292337
23302338 setIsEnablingAutomaticUpdates(true);
23312339 try {
23322340 const results = await Promise.all(
2333− connectedEnvironments.map((environment) =>
2341+ automaticUpdateEnvironments.map((environment) =>
23342342 updateEnvironmentSettings({
23352343 environmentId: environment.environmentId,
23362344 input: { patch: { automaticallyUpdateWhenIdle: true } },
@@ -3508,21 +3516,34 @@ export function ConnectionsSettings() { · 1171 unmodified lines
35083516 {...searchableSetting("remote-environments")}
35093517 headerAction={
35103518 <div className="flex items-center gap-1">
3511− <Button
3512− size="xs"
3513− variant="ghost"
3514− className="h-5 gap-1 rounded-sm px-1 text-[11px] font-normal text-muted-foreground/60 hover:text-muted-foreground"
3515− disabled={
3516− isEnablingAutomaticUpdates ||
3517− !environments.some((environment) => environment.connection.phase === "connected")
3518− }
3519− onClick={() => void handleEnableAutomaticUpdates()}
3520− >
3521− {isEnablingAutomaticUpdates ? (
3522− <RefreshCwIcon className="size-3 animate-spin" />
3523− ) : null}
3524− <span>{isEnablingAutomaticUpdates ? "Applying…" : "Enable auto-updates"}</span>
3525− </Button>
3519+ <Tooltip>
3520+ <TooltipTrigger
3521+ render={
3522+ <span className="inline-flex">
3523+ <Button
3524+ size="xs"
3525+ variant="ghost-muted"
3526+ disabled={
3527+ isEnablingAutomaticUpdates || automaticUpdateEnvironments.length === 0
3528+ }
3529+ onClick={() => void handleEnableAutomaticUpdates()}
3530+ >
3531+ {isEnablingAutomaticUpdates ? (
3532+ <RefreshCwIcon className="size-3 animate-spin" />
3533+ ) : null}
3534+ <span>
3535+ {isEnablingAutomaticUpdates ? "Applying…" : "Enable auto-updates"}
3536+ </span>
3537+ </Button>
3538+ </span>
3539+ }
3540+ />
3541+ <TooltipPopup side="top">
3542+ {automaticUpdateEnvironments.length === 0
3543+ ? "Connect a background-service environment to enable automatic updates."
3544+ : "Enable automatic updates for every connected background-service environment."}
3545+ </TooltipPopup>
3546+ </Tooltip>
35263547 <Dialog
35273548 open={addBackendDialogOpen}
35283549 onOpenChange={(open) => {
@@ -3537,12 +3558,7 @@ export function ConnectionsSettings() { · 8 unmodified lines
35373558 render={
35383559 <DialogTrigger
35393560 render={
3540− <Button
3541− size="xs"
3542− variant="ghost"
3543− className="h-5 gap-1 rounded-sm px-1 text-[11px] font-normal text-muted-foreground/60 hover:text-muted-foreground"
3544− aria-label="Add environment"
3545− >
3561+ <Button size="xs" variant="ghost-muted" aria-label="Add environment">
35463562 <PlusIcon className="size-3" />
35473563 <span>Add environment</span>
35483564 </Button>
packages/client-runtime/src/state/server.test.ts+6−0
@@ -353,6 +353,12 @@ describe("server state projection", () => { · 352 unmodified lines
353353 expect(serverUpdateStateForAutomaticDecision(pending, null)).toEqual({ status: "idle" });
354354 expect(serverUpdateStateForAutomaticDecision(running, pending)).toBe(running);
355355 expect(serverUpdateStateForAutomaticDecision(failed, pending)).toBe(failed);
356+ expect(
357+ serverUpdateStateForAutomaticDecision(failed, {
358+ ...pending,
359+ targetVersion: "0.0.32",
360+ }),
361+ ).toEqual({ ...pending, targetVersion: "0.0.32" });
356362 });
357363
358364 it.effect("correlates launcher outcomes and fails immediately after rollback", () =>
packages/client-runtime/src/state/server.ts+9−1
@@ -252,7 +252,15 @@ export function serverUpdateStateForAutomaticDecision( · 251 unmodified lines
252252 current: ServerUpdateState,
253253 pending: Extract<ServerUpdateState, { status: "pending" }> | null,
254254 ): ServerUpdateState {
255− if (current.status === "running" || current.status === "failed") {
255+ if (current.status === "running") {
256+ return current;
257+ }
258+ if (
259+ current.status === "failed" &&
260+ pending !== null &&
261+ current.fromVersion === pending.fromVersion &&
262+ current.targetVersion === pending.targetVersion
263+ ) {
256264 return current;
257265 }
258266 return pending ?? IDLE_SERVER_UPDATE_STATE;