Unified · Split
| @@ -41,12 +41,12 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; · 40 unmodified lines | ||
| 41 | 41 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 42 | 42 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 43 | 43 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 44 | −import { ServerSelfUpdate } from "./cloud/selfUpdate.ts"; | |
| 44 | +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | |
| 45 | 45 | |
| 46 | 46 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 47 | 47 | const ServerSelfUpdatePassThroughLayer = Layer.succeed( |
| 48 | − ServerSelfUpdate, | |
| 49 | − ServerSelfUpdate.of({ | |
| 48 | + ServerSelfUpdate.ServerSelfUpdate, | |
| 49 | + ServerSelfUpdate.ServerSelfUpdate.of({ | |
| 50 | 50 | update: () => Effect.die(new Error("server update is unused in this test")), |
| 51 | 51 | withCommandAdmission: (effect) => effect, |
| 52 | 52 | }), |
| @@ -19,6 +19,7 @@ interface HarnessOptions { · 18 unmodified lines | ||
| 19 | 19 | readonly mode?: "web" | "desktop"; |
| 20 | 20 | readonly managed?: boolean; |
| 21 | 21 | readonly preflight?: "ready" | "blocked"; |
| 22 | + readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>; | |
| 22 | 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 | 91 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 91 | 92 | Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), |
| 92 | 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 | 100 | Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), |
| 94 | 101 | ); |
| 95 | 102 | return { selfUpdate, order }; |
| @@ -135,7 +142,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 39 unmodified lines | ||
| 135 | 142 | Effect.gen(function* () { |
| 136 | 143 | const { selfUpdate, order } = yield* makeHarness(); |
| 137 | 144 | const error = yield* selfUpdate |
| 138 | − .update({ targetVersion: "0.0.1", automatic: true }, undefined, () => Effect.void) | |
| 145 | + .update({ targetVersion: "0.0.1", automatic: true }) | |
| 139 | 146 | .pipe(Effect.flip); |
| 140 | 147 | |
| 141 | 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* 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); | |
| 168 | 176 | expect(requests).toBe(1); |
| 169 | 177 | |
| 170 | 178 | yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip); |
| 171 | 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 | 183 | it.effect("rechecks automatic updates after staging and before activation", () => |
| 180 | 184 | 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) => | |
| 184 | 187 | Effect.sync(() => order.push("idle-check")).pipe( |
| 185 | 188 | Effect.andThen( |
| 186 | 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 | 199 | .pipe(Effect.flip); |
| 195 | 200 | |
| 196 | 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 | 217 | const updateFiber = yield* selfUpdate |
| 213 | − .update({ targetVersion: "1.1.0", automatic: true }, undefined, () => Effect.void) | |
| 218 | + .update({ targetVersion: "1.1.0", automatic: true }) | |
| 214 | 219 | .pipe(Effect.forkChild); |
| 215 | 220 | yield* Deferred.await(requestStarted); |
| 216 | 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 | 239 | it.effect("blocks new commands after an automatic handoff is accepted", () => |
| 235 | 240 | Effect.gen(function* () { |
| 236 | 241 | 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 }); | |
| 242 | 243 | |
| 243 | 244 | const error = yield* selfUpdate |
| 244 | 245 | .withCommandAdmission(Effect.succeed("started")) |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type ServerSelfUpdateResult, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 9 | +import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; | |
| 9 | 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 10 | 11 | import * as Context from "effect/Context"; |
| 11 | 12 | import * as Duration from "effect/Duration"; |
| @@ -18,6 +19,7 @@ import * as Semaphore from "effect/Semaphore"; · 6 unmodified lines | ||
| 18 | 19 | |
| 19 | 20 | import * as ServerConfig from "../config.ts"; |
| 20 | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 22 | +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 21 | 23 | import { |
| 22 | 24 | ensurePinnedRuntimeInstalled, |
| 23 | 25 | PinnedRuntimeInstallError, |
| @@ -44,7 +46,6 @@ export class ServerSelfUpdate extends Context.Service< · 20 unmodified lines | ||
| 44 | 46 | readonly update: ( |
| 45 | 47 | input: ServerSelfUpdateInput, |
| 46 | 48 | reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>, |
| 47 | − confirmAutomaticUpdateIdle?: () => Effect.Effect<void, ServerSelfUpdateError>, | |
| 48 | 49 | ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>; |
| 49 | 50 | readonly withCommandAdmission: <A, E, R>( |
| 50 | 51 | effect: Effect.Effect<A, E, R>, |
| @@ -52,6 +53,38 @@ export class ServerSelfUpdate extends Context.Service< · 1 unmodified line | ||
| 52 | 53 | } |
| 53 | 54 | >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} |
| 54 | 55 | |
| 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 | + | |
| 55 | 88 | export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 56 | 89 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 57 | 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 | 92 | const fs = yield* FileSystem.FileSystem; |
| 60 | 93 | const path = yield* Path.Path; |
| 61 | 94 | const execPath = yield* HostProcessExecutablePath; |
| 95 | + const idleCheck = yield* ServerSelfUpdateIdleCheck; | |
| 62 | 96 | const inFlight = yield* Ref.make(false); |
| 63 | 97 | const lastFailedTarget = yield* Ref.make<string | null>(null); |
| 64 | 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 | 124 | const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( |
| 91 | 125 | "cloud.server_self_update.update", |
| 92 | − )(function* (input, reportProgress = () => Effect.void, confirmAutomaticUpdateIdle) { | |
| 126 | + )(function* (input, reportProgress = () => Effect.void) { | |
| 93 | 127 | if (capability === "desktop-managed") { |
| 94 | 128 | return yield* failWith( |
| 95 | 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 | 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 | 150 | return yield* failWith( |
| 116 | 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 | 154 | if (yield* Ref.getAndSet(inFlight, true)) { |
| 120 | 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 | 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 | 246 | if (input.automatic === true) { |
| 212 | 247 | yield* commandAdmission.withPermits(1)( |
| 213 | 248 | 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; | |
| 218 | 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 | 282 | Ref.set(inFlight, false), |
| 251 | 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 | 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 | |
| 264 | 303 | export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( |
| 265 | 304 | Layer.provide(ProcessRunner.layer), |
| 305 | + Layer.provide(idleCheckLayer), | |
| 266 | 306 | ); |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "../auth/http.ts"; |
| 19 | 19 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 20 | 20 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; |
| 21 | −import { ServerSelfUpdate } from "../cloud/selfUpdate.ts"; | |
| 21 | +import * as ServerSelfUpdate from "../cloud/selfUpdate.ts"; | |
| 22 | 22 | |
| 23 | 23 | export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 24 | 24 | EnvironmentHttpApi, |
| @@ -26,7 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 1 unmodified line | ||
| 26 | 26 | Effect.fnUntraced(function* (handlers) { |
| 27 | 27 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 28 | 28 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 29 | − const serverSelfUpdate = yield* ServerSelfUpdate; | |
| 29 | + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | |
| 30 | 30 | |
| 31 | 31 | return handlers |
| 32 | 32 | .handle( |
| @@ -59,7 +59,7 @@ import { · 58 unmodified lines | ||
| 59 | 59 | WsRpcGroup, |
| 60 | 60 | } from "@t3tools/contracts"; |
| 61 | 61 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 62 | −import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; | |
| 62 | + | |
| 63 | 63 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 64 | 64 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 65 | 65 | |
| @@ -489,25 +489,6 @@ const makeWsRpcLayer = ( · 423 unmodified lines | ||
| 489 | 489 | const serverEventId = randomUUID.pipe(Effect.map(EventId.make)); |
| 490 | 490 | const serverCommandId = (tag: string) => |
| 491 | 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 | 493 | const loadAuthAccessSnapshot = () => |
| 513 | 494 | Effect.all({ |
| @@ -1487,26 +1468,19 @@ const makeWsRpcLayer = ( · 973 unmodified lines | ||
| 1487 | 1468 | }, |
| 1488 | 1469 | ), |
| 1489 | 1470 | [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 | + }), | |
| 1497 | 1474 | [WS_METHODS.serverUpdateServerWithProgress]: (input) => |
| 1498 | 1475 | observeRpcStream( |
| 1499 | 1476 | WS_METHODS.serverUpdateServerWithProgress, |
| 1500 | 1477 | Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) => |
| 1501 | 1478 | 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), | |
| 1510 | 1484 | ) |
| 1511 | 1485 | .pipe( |
| 1512 | 1486 | Effect.flatMap((result) => |
| @@ -73,7 +73,15 @@ function EnvironmentAutomaticServerUpdate({ · 72 unmodified lines | ||
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 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 | 85 | attemptedTargetRef.current = targetKey; |
| 78 | 86 | return; |
| 79 | 87 | } |
| @@ -2133,8 +2133,8 @@ function ChatViewContent(props: ChatViewProps) { · 2132 unmodified lines | ||
| 2133 | 2133 | (serverUpdateState.status !== "idle" || |
| 2134 | 2134 | (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) |
| 2135 | 2135 | ) { |
| 2136 | − const updateInProgress = | |
| 2137 | − serverUpdateState.status === "running" || serverUpdateState.status === "pending"; | |
| 2136 | + const updateInProgress = serverUpdateState.status === "running"; | |
| 2137 | + const updatePending = serverUpdateState.status === "pending"; | |
| 2138 | 2138 | const updateFailed = serverUpdateState.status === "failed"; |
| 2139 | 2139 | items.push({ |
| 2140 | 2140 | id: `server-version:${serverUpdateEnvironmentId}`, |
| @@ -2145,15 +2145,15 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines | ||
| 2145 | 2145 | // In-flight and failed states carry their own status dot inside |
| 2146 | 2146 | // ServerUpdateProgress; only the idle offer needs an icon. |
| 2147 | 2147 | icon: |
| 2148 | − updateInProgress || updateFailed ? null : ( | |
| 2148 | + updateInProgress || updatePending || updateFailed ? null : ( | |
| 2149 | 2149 | <span |
| 2150 | 2150 | className="size-1.5 rounded-full border border-muted-foreground/40" |
| 2151 | 2151 | aria-hidden="true" |
| 2152 | 2152 | /> |
| 2153 | 2153 | ), |
| 2154 | 2154 | 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}` | |
| 2157 | 2157 | ) : versionMismatch ? ( |
| 2158 | 2158 | <Tooltip> |
| 2159 | 2159 | <TooltipTrigger |
| @@ -2172,7 +2172,7 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines | ||
| 2172 | 2172 | "Server update available" |
| 2173 | 2173 | ), |
| 2174 | 2174 | description: |
| 2175 | − updateInProgress || updateFailed ? ( | |
| 2175 | + updateInProgress || updatePending || updateFailed ? ( | |
| 2176 | 2176 | <ServerUpdateProgress state={serverUpdateState} /> |
| 2177 | 2177 | ) : versionMismatchSelfUpdate === "desktop-managed" ? ( |
| 2178 | 2178 | serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel) |
| @@ -1529,11 +1529,13 @@ function SavedBackendListRow({ · 1528 unmodified lines | ||
| 1529 | 1529 | </TooltipPopup> |
| 1530 | 1530 | </Tooltip> |
| 1531 | 1531 | ) : 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} | |
| 1537 | 1539 | {environment.connection.error && !resumingServerUpdate ? ( |
| 1538 | 1540 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> |
| 1539 | 1541 | <span className="truncate">{connectionStatusText(environment.connection)}</span> |
| @@ -1818,6 +1820,15 @@ function CloudRemoteEnvironmentRows({ · 278 unmodified lines | ||
| 1818 | 1820 | export function ConnectionsSettings() { |
| 1819 | 1821 | const desktopBridge = window.desktopBridge; |
| 1820 | 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 | 1832 | const primaryEnvironment = usePrimaryEnvironment(); |
| 1822 | 1833 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); |
| 1823 | 1834 | const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { |
| @@ -2322,15 +2333,12 @@ export function ConnectionsSettings() { · 498 unmodified lines | ||
| 2322 | 2333 | ]); |
| 2323 | 2334 | |
| 2324 | 2335 | 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; | |
| 2329 | 2337 | |
| 2330 | 2338 | setIsEnablingAutomaticUpdates(true); |
| 2331 | 2339 | try { |
| 2332 | 2340 | const results = await Promise.all( |
| 2333 | − connectedEnvironments.map((environment) => | |
| 2341 | + automaticUpdateEnvironments.map((environment) => | |
| 2334 | 2342 | updateEnvironmentSettings({ |
| 2335 | 2343 | environmentId: environment.environmentId, |
| 2336 | 2344 | input: { patch: { automaticallyUpdateWhenIdle: true } }, |
| @@ -3508,21 +3516,34 @@ export function ConnectionsSettings() { · 1171 unmodified lines | ||
| 3508 | 3516 | {...searchableSetting("remote-environments")} |
| 3509 | 3517 | headerAction={ |
| 3510 | 3518 | <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> | |
| 3526 | 3547 | <Dialog |
| 3527 | 3548 | open={addBackendDialogOpen} |
| 3528 | 3549 | onOpenChange={(open) => { |
| @@ -3537,12 +3558,7 @@ export function ConnectionsSettings() { · 8 unmodified lines | ||
| 3537 | 3558 | render={ |
| 3538 | 3559 | <DialogTrigger |
| 3539 | 3560 | 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"> | |
| 3546 | 3562 | <PlusIcon className="size-3" /> |
| 3547 | 3563 | <span>Add environment</span> |
| 3548 | 3564 | </Button> |
| @@ -353,6 +353,12 @@ describe("server state projection", () => { · 352 unmodified lines | ||
| 353 | 353 | expect(serverUpdateStateForAutomaticDecision(pending, null)).toEqual({ status: "idle" }); |
| 354 | 354 | expect(serverUpdateStateForAutomaticDecision(running, pending)).toBe(running); |
| 355 | 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 | 364 | it.effect("correlates launcher outcomes and fails immediately after rollback", () => |
| @@ -252,7 +252,15 @@ export function serverUpdateStateForAutomaticDecision( · 251 unmodified lines | ||
| 252 | 252 | current: ServerUpdateState, |
| 253 | 253 | pending: Extract<ServerUpdateState, { status: "pending" }> | null, |
| 254 | 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 | 264 | return current; |
| 257 | 265 | } |
| 258 | 266 | return pending ?? IDLE_SERVER_UPDATE_STATE; |