Unified · Split
| @@ -41,12 +41,12 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; · 40 unmodified lines | |||
| 41 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 41 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 42 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 42 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 43 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 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 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 46 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 47 | const ServerSelfUpdatePassThroughLayer = Layer.succeed( | 47 | const 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 | }), |
| @@ -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* selfUpdate | 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 | .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* selfUpdate | 169 | 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* selfUpdate | 172 | |
| 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* selfUpdate | 186 | 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* selfUpdate | 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 | .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* selfUpdate | 244 | const error = yield* selfUpdate |
| 244 | .withCommandAdmission(Effect.succeed("started")) | 245 | .withCommandAdmission(Effect.succeed("started")) |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | type ServerSelfUpdateResult, | 6 | type ServerSelfUpdateResult, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; | 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 9 | import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; | ||
| 9 | import { compareSemverVersions } from "@t3tools/shared/semver"; | 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 10 | import * as Context from "effect/Context"; | 11 | import * as Context from "effect/Context"; |
| 11 | import * as Duration from "effect/Duration"; | 12 | import * as Duration from "effect/Duration"; |
| @@ -18,6 +19,7 @@ import * as Semaphore from "effect/Semaphore"; · 6 unmodified lines | |||
| 18 | 19 | ||
| 19 | import * as ServerConfig from "../config.ts"; | 20 | import * as ServerConfig from "../config.ts"; |
| 20 | import * as ProcessRunner from "../processRunner.ts"; | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 22 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 21 | import { | 23 | import { |
| 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 | ||
| 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 | export const make = Effect.fn("cloud.server_self_update.make")(function* () { | 88 | export 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 | ||
| 264 | export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( | 303 | export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( |
| 265 | Layer.provide(ProcessRunner.layer), | 304 | Layer.provide(ProcessRunner.layer), |
| 305 | Layer.provide(idleCheckLayer), | ||
| 266 | ); | 306 | ); |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | } from "../auth/http.ts"; | 18 | } from "../auth/http.ts"; |
| 19 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | 19 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 20 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | 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 | export const orchestrationHttpApiLayer = HttpApiBuilder.group( | 23 | export 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 handlers | 31 | return handlers |
| 32 | .handle( | 32 | .handle( |
| @@ -59,7 +59,7 @@ import { · 58 unmodified lines | |||
| 59 | WsRpcGroup, | 59 | WsRpcGroup, |
| 60 | } from "@t3tools/contracts"; | 60 | } from "@t3tools/contracts"; |
| 61 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | 61 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 62 | import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; | 62 | |
| 63 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; | 63 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 64 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 64 | import { 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 | serverSelfUpdate | 1478 | 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) => |
| @@ -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 | } |
| @@ -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 inside | 2145 | // 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 | <span | 2149 | <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 | <TooltipTrigger | 2159 | <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) |
| @@ -1529,11 +1529,13 @@ function SavedBackendListRow({ · 1528 unmodified lines | |||
| 1529 | </TooltipPopup> | 1529 | </TooltipPopup> |
| 1530 | </Tooltip> | 1530 | </Tooltip> |
| 1531 | ) : null} | 1531 | ) : null} |
| 1532 | <AutomaticServerUpdateControl | 1532 | {environment.serverConfig !== null ? ( |
| 1533 | environmentId={environmentId} | 1533 | <AutomaticServerUpdateControl |
| 1534 | serverConfig={environment.serverConfig} | 1534 | environmentId={environmentId} |
| 1535 | compact | 1535 | 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 | |||
| 1818 | export function ConnectionsSettings() { | 1820 | export 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 | <Button | 3519 | <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 | <Dialog | 3547 | <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 | <DialogTrigger | 3559 | <DialogTrigger |
| 3539 | render={ | 3560 | render={ |
| 3540 | <Button | 3561 | <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> |
| @@ -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", () => |
| @@ -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; |