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
41import * as ServerSecretStore from "./auth/ServerSecretStore.ts";41import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
42import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";42import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
43import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";43import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
44import { ServerSelfUpdate } from "./cloud/selfUpdate.ts";44import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
45 45
46const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);46const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
47const ServerSelfUpdatePassThroughLayer = Layer.succeed(47const ServerSelfUpdatePassThroughLayer = Layer.succeed(
48 ServerSelfUpdate,48 ServerSelfUpdate.ServerSelfUpdate,
49 ServerSelfUpdate.of({49 ServerSelfUpdate.ServerSelfUpdate.of({
50 update: () => Effect.die(new Error("server update is unused in this test")),50 update: () => Effect.die(new Error("server update is unused in this test")),
51 withCommandAdmission: (effect) => effect,51 withCommandAdmission: (effect) => effect,
52 }),52 }),
apps/server/src/cloud/selfUpdate.test.ts+22−21
@@ -19,6 +19,7 @@ interface HarnessOptions { · 18 unmodified lines
19 readonly mode?: "web" | "desktop";19 readonly mode?: "web" | "desktop";
20 readonly managed?: boolean;20 readonly managed?: boolean;
21 readonly preflight?: "ready" | "blocked";21 readonly preflight?: "ready" | "blocked";
22 readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>;
22 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];23 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
23}24}
24 25
@@ -90,6 +91,12 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( · 65 unmodified lines
90 Effect.provideService(ProcessRunner.ProcessRunner, runner),91 Effect.provideService(ProcessRunner.ProcessRunner, runner),
91 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),92 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
92 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),93 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 ),
93 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),100 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
94 );101 );
95 return { selfUpdate, order };102 return { selfUpdate, order };
@@ -135,7 +142,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 39 unmodified lines
135 Effect.gen(function* () {142 Effect.gen(function* () {
136 const { selfUpdate, order } = yield* makeHarness();143 const { selfUpdate, order } = yield* makeHarness();
137 const error = yield* selfUpdate144 const error = yield* selfUpdate
138 .update({ targetVersion: "0.0.1", automatic: true }, undefined, () => Effect.void)145 .update({ targetVersion: "0.0.1", automatic: true })
139 .pipe(Effect.flip);146 .pipe(Effect.flip);
140 147
141 expect(error.reason).toContain("newer version");148 expect(error.reason).toContain("newer version");
@@ -159,28 +166,24 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 17 unmodified lines
159 ),166 ),
160 });167 });
161 168
162 yield* selfUpdate169 yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
163 .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)170 yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
164 .pipe(Effect.flip);171 expect(requests).toBe(1);
165 yield* selfUpdate172
166 .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)173 yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip);
167 .pipe(Effect.flip);174 expect(requests).toBe(1);
175 yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true }).pipe(Effect.flip);
168 expect(requests).toBe(1);176 expect(requests).toBe(1);
169 177
170 yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip);178 yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip);
171 expect(requests).toBe(2);179 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);
176 }),180 }),
177 );181 );
178 182
179 it.effect("rechecks automatic updates after staging and before activation", () =>183 it.effect("rechecks automatic updates after staging and before activation", () =>
180 Effect.gen(function* () {184 Effect.gen(function* () {
181 const { selfUpdate, order } = yield* makeHarness();185 const { selfUpdate, order } = yield* makeHarness({
182 const error = yield* selfUpdate186 automaticUpdateIdle: (order) =>
183 .update({ targetVersion: "1.1.0", automatic: true }, undefined, () =>
184 Effect.sync(() => order.push("idle-check")).pipe(187 Effect.sync(() => order.push("idle-check")).pipe(
185 Effect.andThen(188 Effect.andThen(
186 Effect.fail(189 Effect.fail(
@@ -190,7 +193,9 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 3 unmodified lines
190 ),193 ),
191 ),194 ),
192 ),195 ),
193 )196 });
197 const error = yield* selfUpdate
198 .update({ targetVersion: "1.1.0", automatic: true })
194 .pipe(Effect.flip);199 .pipe(Effect.flip);
195 200
196 expect(error.reason).toContain("new work started");201 expect(error.reason).toContain("new work started");
@@ -210,7 +215,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 13 unmodified lines
210 ),215 ),
211 });216 });
212 const updateFiber = yield* selfUpdate217 const updateFiber = yield* selfUpdate
213 .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void)218 .update({ targetVersion: "1.1.0", automatic: true })
214 .pipe(Effect.forkChild);219 .pipe(Effect.forkChild);
215 yield* Deferred.await(requestStarted);220 yield* Deferred.await(requestStarted);
216 const interruptFiber = yield* Fiber.interrupt(updateFiber).pipe(Effect.forkChild);221 const interruptFiber = yield* Fiber.interrupt(updateFiber).pipe(Effect.forkChild);
@@ -234,11 +239,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 17 unmodified lines
234 it.effect("blocks new commands after an automatic handoff is accepted", () =>239 it.effect("blocks new commands after an automatic handoff is accepted", () =>
235 Effect.gen(function* () {240 Effect.gen(function* () {
236 const { selfUpdate } = yield* makeHarness();241 const { selfUpdate } = yield* makeHarness();
237 yield* selfUpdate.update(242 yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true });
238 { targetVersion: "1.1.0", automatic: true },
239 undefined,
240 () => Effect.void,
241 );
242 243
243 const error = yield* selfUpdate244 const error = yield* selfUpdate
244 .withCommandAdmission(Effect.succeed("started"))245 .withCommandAdmission(Effect.succeed("started"))
apps/server/src/cloud/selfUpdate.ts+49−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 type ServerSelfUpdateResult,6 type ServerSelfUpdateResult,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";8import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
9import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";
9import { compareSemverVersions } from "@t3tools/shared/semver";10import { compareSemverVersions } from "@t3tools/shared/semver";
10import * as Context from "effect/Context";11import * as Context from "effect/Context";
11import * as Duration from "effect/Duration";12import * as Duration from "effect/Duration";
@@ -18,6 +19,7 @@ import * as Semaphore from "effect/Semaphore"; · 6 unmodified lines
18 19
19import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
20import * as ProcessRunner from "../processRunner.ts";21import * as ProcessRunner from "../processRunner.ts";
22import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
21import {23import {
22 ensurePinnedRuntimeInstalled,24 ensurePinnedRuntimeInstalled,
23 PinnedRuntimeInstallError,25 PinnedRuntimeInstallError,
@@ -44,7 +46,6 @@ export class ServerSelfUpdate extends Context.Service< · 20 unmodified lines
44 readonly update: (46 readonly update: (
45 input: ServerSelfUpdateInput,47 input: ServerSelfUpdateInput,
46 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,48 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
47 confirmAutomaticUpdateIdle?: () => Effect.Effect<void, ServerSelfUpdateError>,
48 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;49 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
49 readonly withCommandAdmission: <A, E, R>(50 readonly withCommandAdmission: <A, E, R>(
50 effect: Effect.Effect<A, E, R>,51 effect: Effect.Effect<A, E, R>,
@@ -52,6 +53,38 @@ export class ServerSelfUpdate extends Context.Service< · 1 unmodified line
52 }53 }
53>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}54>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
54 55
56export class ServerSelfUpdateIdleCheck extends Context.Service<
57 ServerSelfUpdateIdleCheck,
58 { readonly assertIdle: Effect.Effect<void, ServerSelfUpdateError> }
59>()("t3/cloud/selfUpdate/ServerSelfUpdateIdleCheck") {}
60
61const 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
55export const make = Effect.fn("cloud.server_self_update.make")(function* () {88export const make = Effect.fn("cloud.server_self_update.make")(function* () {
56 const serverConfig = yield* ServerConfig.ServerConfig;89 const serverConfig = yield* ServerConfig.ServerConfig;
57 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;90 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
@@ -59,6 +92,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 1 unmodified line
59 const fs = yield* FileSystem.FileSystem;92 const fs = yield* FileSystem.FileSystem;
60 const path = yield* Path.Path;93 const path = yield* Path.Path;
61 const execPath = yield* HostProcessExecutablePath;94 const execPath = yield* HostProcessExecutablePath;
95 const idleCheck = yield* ServerSelfUpdateIdleCheck;
62 const inFlight = yield* Ref.make(false);96 const inFlight = yield* Ref.make(false);
63 const lastFailedTarget = yield* Ref.make<string | null>(null);97 const lastFailedTarget = yield* Ref.make<string | null>(null);
64 const automaticHandoffPending = yield* Ref.make(false);98 const automaticHandoffPending = yield* Ref.make(false);
@@ -89,7 +123,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 24 unmodified lines
89 123
90 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(124 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
91 "cloud.server_self_update.update",125 "cloud.server_self_update.update",
92 )(function* (input, reportProgress = () => Effect.void, confirmAutomaticUpdateIdle) {126 )(function* (input, reportProgress = () => Effect.void) {
93 if (capability === "desktop-managed") {127 if (capability === "desktop-managed") {
94 return yield* failWith(128 return yield* failWith(
95 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",129 "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
111 ) {145 ) {
112 return yield* failWith("Automatic updates must move the server to a newer version.");146 return yield* failWith("Automatic updates must move the server to a newer version.");
113 }147 }
114 if (input.automatic === true && (yield* Ref.get(lastFailedTarget)) === targetVersion) {148 const previousFailedTarget = yield* Ref.get(lastFailedTarget);
149 if (input.automatic === true && previousFailedTarget === targetVersion) {
115 return yield* failWith(150 return yield* failWith(
116 "An automatic update to this version already failed. Retry it manually before automatic updates resume.",151 "An automatic update to this version already failed. Retry it manually before automatic updates resume.",
117 );152 );
@@ -119,7 +154,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 1 unmodified line
119 if (yield* Ref.getAndSet(inFlight, true)) {154 if (yield* Ref.getAndSet(inFlight, true)) {
120 return yield* failWith("A server update is already in progress.");155 return yield* failWith("A server update is already in progress.");
121 }156 }
122 if (input.automatic !== true) {157 if (input.automatic !== true && previousFailedTarget === targetVersion) {
123 yield* Ref.set(lastFailedTarget, null);158 yield* Ref.set(lastFailedTarget, null);
124 }159 }
125 160
@@ -211,10 +246,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 85 unmodified lines
211 if (input.automatic === true) {246 if (input.automatic === true) {
212 yield* commandAdmission.withPermits(1)(247 yield* commandAdmission.withPermits(1)(
213 Effect.gen(function* () {248 Effect.gen(function* () {
214 if (confirmAutomaticUpdateIdle === undefined) {249 yield* idleCheck.assertIdle;
215 return yield* failWith("Automatic update could not verify that this server is idle.");
216 }
217 yield* confirmAutomaticUpdateIdle();
218 yield* Ref.set(automaticHandoffPending, true);250 yield* Ref.set(automaticHandoffPending, true);
219 }),251 }),
220 );252 );
@@ -249,7 +281,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 28 unmodified lines
249 [281 [
250 Ref.set(inFlight, false),282 Ref.set(inFlight, false),
251 Ref.set(automaticHandoffPending, false),283 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 ),
253 ],292 ],
254 { discard: true },293 { discard: true },
255 ),294 ),
@@ -263,4 +302,5 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 7 unmodified lines
263 302
264export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(303export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
265 Layer.provide(ProcessRunner.layer),304 Layer.provide(ProcessRunner.layer),
305 Layer.provide(idleCheckLayer),
266);306);
apps/server/src/orchestration/http.ts+2−2
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18} from "../auth/http.ts";18} from "../auth/http.ts";
19import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";19import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
20import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";20import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
21import { ServerSelfUpdate } from "../cloud/selfUpdate.ts";21import * as ServerSelfUpdate from "../cloud/selfUpdate.ts";
22 22
23export const orchestrationHttpApiLayer = HttpApiBuilder.group(23export const orchestrationHttpApiLayer = HttpApiBuilder.group(
24 EnvironmentHttpApi,24 EnvironmentHttpApi,
@@ -26,7 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 1 unmodified line
26 Effect.fnUntraced(function* (handlers) {26 Effect.fnUntraced(function* (handlers) {
27 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;27 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
28 const orchestrationEngine = yield* OrchestrationEngineService;28 const orchestrationEngine = yield* OrchestrationEngineService;
29 const serverSelfUpdate = yield* ServerSelfUpdate;29 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
30 30
31 return handlers31 return handlers
32 .handle(32 .handle(
apps/server/src/ws.ts+9−35
@@ -59,7 +59,7 @@ import { · 58 unmodified lines
59 WsRpcGroup,59 WsRpcGroup,
60} from "@t3tools/contracts";60} from "@t3tools/contracts";
61import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";61import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
62import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";62
63import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";63import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
64import { RpcSerialization, RpcServer } from "effect/unstable/rpc";64import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
65 65
@@ -489,25 +489,6 @@ const makeWsRpcLayer = ( · 423 unmodified lines
489 const serverEventId = randomUUID.pipe(Effect.map(EventId.make));489 const serverEventId = randomUUID.pipe(Effect.map(EventId.make));
490 const serverCommandId = (tag: string) =>490 const serverCommandId = (tag: string) =>
491 randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));491 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 );
511 492
512 const loadAuthAccessSnapshot = () =>493 const loadAuthAccessSnapshot = () =>
513 Effect.all({494 Effect.all({
@@ -1487,26 +1468,19 @@ const makeWsRpcLayer = ( · 973 unmodified lines
1487 },1468 },
1488 ),1469 ),
1489 [WS_METHODS.serverUpdateServer]: (input) =>1470 [WS_METHODS.serverUpdateServer]: (input) =>
1490 observeRpcEffect(1471 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
1491 WS_METHODS.serverUpdateServer,1472 "rpc.aggregate": "server",
1492 serverSelfUpdate.update(input, undefined, confirmAutomaticUpdateIdle),1473 }),
1493 {
1494 "rpc.aggregate": "server",
1495 },
1496 ),
1497 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>1474 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>
1498 observeRpcStream(1475 observeRpcStream(
1499 WS_METHODS.serverUpdateServerWithProgress,1476 WS_METHODS.serverUpdateServerWithProgress,
1500 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>1477 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
1501 serverSelfUpdate1478 serverSelfUpdate
1502 .update(1479 .update(input, (stage) =>
1503 input,1480 Queue.offer(queue, {
1504 (stage) =>1481 type: "progress",
1505 Queue.offer(queue, {1482 stage,
1506 type: "progress",1483 }).pipe(Effect.asVoid),
1507 stage,
1508 }).pipe(Effect.asVoid),
1509 confirmAutomaticUpdateIdle,
1510 )1484 )
1511 .pipe(1485 .pipe(
1512 Effect.flatMap((result) =>1486 Effect.flatMap((result) =>
apps/web/src/components/AutomaticServerUpdateCoordinator.tsx+9−1
@@ -73,7 +73,15 @@ function EnvironmentAutomaticServerUpdate({ · 72 unmodified lines
73 }73 }
74 74
75 const targetKey = `${fromVersion}->${targetVersion}`;75 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 ) {
77 attemptedTargetRef.current = targetKey;85 attemptedTargetRef.current = targetKey;
78 return;86 return;
79 }87 }
apps/web/src/components/ChatView.tsx+6−6
@@ -2133,8 +2133,8 @@ function ChatViewContent(props: ChatViewProps) { · 2132 unmodified lines
2133 (serverUpdateState.status !== "idle" ||2133 (serverUpdateState.status !== "idle" ||
2134 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))2134 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
2135 ) {2135 ) {
2136 const updateInProgress =2136 const updateInProgress = serverUpdateState.status === "running";
2137 serverUpdateState.status === "running" || serverUpdateState.status === "pending";2137 const updatePending = serverUpdateState.status === "pending";
2138 const updateFailed = serverUpdateState.status === "failed";2138 const updateFailed = serverUpdateState.status === "failed";
2139 items.push({2139 items.push({
2140 id: `server-version:${serverUpdateEnvironmentId}`,2140 id: `server-version:${serverUpdateEnvironmentId}`,
@@ -2145,15 +2145,15 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines
2145 // In-flight and failed states carry their own status dot inside2145 // In-flight and failed states carry their own status dot inside
2146 // ServerUpdateProgress; only the idle offer needs an icon.2146 // ServerUpdateProgress; only the idle offer needs an icon.
2147 icon:2147 icon:
2148 updateInProgress || updateFailed ? null : (2148 updateInProgress || updatePending || updateFailed ? null : (
2149 <span2149 <span
2150 className="size-1.5 rounded-full border border-muted-foreground/40"2150 className="size-1.5 rounded-full border border-muted-foreground/40"
2151 aria-hidden="true"2151 aria-hidden="true"
2152 />2152 />
2153 ),2153 ),
2154 title:2154 title:
2155 updateInProgress || updateFailed ? (2155 updateInProgress || updatePending || updateFailed ? (
2156 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`2156 `${updateFailed ? "Could not update" : updatePending ? "Automatic update waiting for" : "Updating"} ${versionMismatchServerLabel}`
2157 ) : versionMismatch ? (2157 ) : versionMismatch ? (
2158 <Tooltip>2158 <Tooltip>
2159 <TooltipTrigger2159 <TooltipTrigger
@@ -2172,7 +2172,7 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines
2172 "Server update available"2172 "Server update available"
2173 ),2173 ),
2174 description:2174 description:
2175 updateInProgress || updateFailed ? (2175 updateInProgress || updatePending || updateFailed ? (
2176 <ServerUpdateProgress state={serverUpdateState} />2176 <ServerUpdateProgress state={serverUpdateState} />
2177 ) : versionMismatchSelfUpdate === "desktop-managed" ? (2177 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2178 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)2178 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
apps/web/src/components/settings/ConnectionsSettings.tsx+47−31
@@ -1529,11 +1529,13 @@ function SavedBackendListRow({ · 1528 unmodified lines
1529 </TooltipPopup>1529 </TooltipPopup>
1530 </Tooltip>1530 </Tooltip>
1531 ) : null}1531 ) : null}
1532 <AutomaticServerUpdateControl1532 {environment.serverConfig !== null ? (
1533 environmentId={environmentId}1533 <AutomaticServerUpdateControl
1534 serverConfig={environment.serverConfig}1534 environmentId={environmentId}
1535 compact1535 serverConfig={environment.serverConfig}
1536 />1536 compact
1537 />
1538 ) : null}
1537 {environment.connection.error && !resumingServerUpdate ? (1539 {environment.connection.error && !resumingServerUpdate ? (
1538 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1540 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
1539 <span className="truncate">{connectionStatusText(environment.connection)}</span>1541 <span className="truncate">{connectionStatusText(environment.connection)}</span>
@@ -1818,6 +1820,15 @@ function CloudRemoteEnvironmentRows({ · 278 unmodified lines
1818export function ConnectionsSettings() {1820export function ConnectionsSettings() {
1819 const desktopBridge = window.desktopBridge;1821 const desktopBridge = window.desktopBridge;
1820 const { environments } = useEnvironments();1822 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 );
1821 const primaryEnvironment = usePrimaryEnvironment();1832 const primaryEnvironment = usePrimaryEnvironment();
1822 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });1833 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
1823 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {1834 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
@@ -2322,15 +2333,12 @@ export function ConnectionsSettings() { · 498 unmodified lines
2322 ]);2333 ]);
2323 2334
2324 const handleEnableAutomaticUpdates = async () => {2335 const handleEnableAutomaticUpdates = async () => {
2325 const connectedEnvironments = environments.filter(2336 if (automaticUpdateEnvironments.length === 0) return;
2326 (environment) => environment.connection.phase === "connected",
2327 );
2328 if (connectedEnvironments.length === 0) return;
2329 2337
2330 setIsEnablingAutomaticUpdates(true);2338 setIsEnablingAutomaticUpdates(true);
2331 try {2339 try {
2332 const results = await Promise.all(2340 const results = await Promise.all(
2333 connectedEnvironments.map((environment) =>2341 automaticUpdateEnvironments.map((environment) =>
2334 updateEnvironmentSettings({2342 updateEnvironmentSettings({
2335 environmentId: environment.environmentId,2343 environmentId: environment.environmentId,
2336 input: { patch: { automaticallyUpdateWhenIdle: true } },2344 input: { patch: { automaticallyUpdateWhenIdle: true } },
@@ -3508,21 +3516,34 @@ export function ConnectionsSettings() { · 1171 unmodified lines
3508 {...searchableSetting("remote-environments")}3516 {...searchableSetting("remote-environments")}
3509 headerAction={3517 headerAction={
3510 <div className="flex items-center gap-1">3518 <div className="flex items-center gap-1">
3511 <Button3519 <Tooltip>
3512 size="xs"3520 <TooltipTrigger
3513 variant="ghost"3521 render={
3514 className="h-5 gap-1 rounded-sm px-1 text-[11px] font-normal text-muted-foreground/60 hover:text-muted-foreground"3522 <span className="inline-flex">
3515 disabled={3523 <Button
3516 isEnablingAutomaticUpdates ||3524 size="xs"
3517 !environments.some((environment) => environment.connection.phase === "connected")3525 variant="ghost-muted"
3518 }3526 disabled={
3519 onClick={() => void handleEnableAutomaticUpdates()}3527 isEnablingAutomaticUpdates || automaticUpdateEnvironments.length === 0
3520 >3528 }
3521 {isEnablingAutomaticUpdates ? (3529 onClick={() => void handleEnableAutomaticUpdates()}
3522 <RefreshCwIcon className="size-3 animate-spin" />3530 >
3523 ) : null}3531 {isEnablingAutomaticUpdates ? (
3524 <span>{isEnablingAutomaticUpdates ? "Applying…" : "Enable auto-updates"}</span>3532 <RefreshCwIcon className="size-3 animate-spin" />
3525 </Button>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>
3526 <Dialog3547 <Dialog
3527 open={addBackendDialogOpen}3548 open={addBackendDialogOpen}
3528 onOpenChange={(open) => {3549 onOpenChange={(open) => {
@@ -3537,12 +3558,7 @@ export function ConnectionsSettings() { · 8 unmodified lines
3537 render={3558 render={
3538 <DialogTrigger3559 <DialogTrigger
3539 render={3560 render={
3540 <Button3561 <Button size="xs" variant="ghost-muted" aria-label="Add environment">
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 >
3546 <PlusIcon className="size-3" />3562 <PlusIcon className="size-3" />
3547 <span>Add environment</span>3563 <span>Add environment</span>
3548 </Button>3564 </Button>
packages/client-runtime/src/state/server.test.ts+6−0
@@ -353,6 +353,12 @@ describe("server state projection", () => { · 352 unmodified lines
353 expect(serverUpdateStateForAutomaticDecision(pending, null)).toEqual({ status: "idle" });353 expect(serverUpdateStateForAutomaticDecision(pending, null)).toEqual({ status: "idle" });
354 expect(serverUpdateStateForAutomaticDecision(running, pending)).toBe(running);354 expect(serverUpdateStateForAutomaticDecision(running, pending)).toBe(running);
355 expect(serverUpdateStateForAutomaticDecision(failed, pending)).toBe(failed);355 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" });
356 });362 });
357 363
358 it.effect("correlates launcher outcomes and fails immediately after rollback", () =>364 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
252 current: ServerUpdateState,252 current: ServerUpdateState,
253 pending: Extract<ServerUpdateState, { status: "pending" }> | null,253 pending: Extract<ServerUpdateState, { status: "pending" }> | null,
254): ServerUpdateState {254): 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 ) {
256 return current;264 return current;
257 }265 }
258 return pending ?? IDLE_SERVER_UPDATE_STATE;266 return pending ?? IDLE_SERVER_UPDATE_STATE;