Unified · Split
| @@ -20,6 +20,7 @@ interface HarnessOptions { · 19 unmodified lines | ||
| 20 | 20 | readonly managed?: boolean; |
| 21 | 21 | readonly preflight?: "ready" | "blocked"; |
| 22 | 22 | readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>; |
| 23 | + readonly install?: (order: string[]) => Effect.Effect<void>; | |
| 23 | 24 | readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; |
| 24 | 25 | } |
| 25 | 26 | |
| @@ -35,6 +36,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( · 9 unmodified lines | ||
| 35 | 36 | Effect.gen(function* () { |
| 36 | 37 | if (input.command === "npm") { |
| 37 | 38 | order.push("install"); |
| 39 | + yield* options.install?.(order) ?? Effect.void; | |
| 38 | 40 | const prefix = input.args[input.args.indexOf("--prefix") + 1]; |
| 39 | 41 | if (prefix === undefined) return yield* Effect.die("missing npm prefix"); |
| 40 | 42 | const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); |
| @@ -236,6 +238,38 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 195 unmodified lines | ||
| 236 | 238 | }), |
| 237 | 239 | ); |
| 238 | 240 | |
| 241 | + it.effect("allows automatic update staging to be interrupted before handoff", () => | |
| 242 | + Effect.gen(function* () { | |
| 243 | + const installStarted = yield* Deferred.make<void>(); | |
| 244 | + const releaseInstall = yield* Deferred.make<void>(); | |
| 245 | + let installs = 0; | |
| 246 | + const { selfUpdate, order } = yield* makeHarness({ | |
| 247 | + install: () => { | |
| 248 | + installs += 1; | |
| 249 | + return installs === 1 | |
| 250 | + ? Deferred.succeed(installStarted, undefined).pipe( | |
| 251 | + Effect.andThen(Deferred.await(releaseInstall)), | |
| 252 | + ) | |
| 253 | + : Effect.void; | |
| 254 | + }, | |
| 255 | + }); | |
| 256 | + const updateFiber = yield* selfUpdate | |
| 257 | + .update({ targetVersion: "1.1.0", automatic: true }) | |
| 258 | + .pipe(Effect.forkChild); | |
| 259 | + yield* Deferred.await(installStarted); | |
| 260 | + const interruptFiber = yield* Fiber.interrupt(updateFiber).pipe(Effect.forkChild); | |
| 261 | + yield* Deferred.succeed(releaseInstall, undefined); | |
| 262 | + yield* Fiber.join(interruptFiber); | |
| 263 | + | |
| 264 | + expect(order).toEqual(["install"]); | |
| 265 | + expect(yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true })).toEqual({ | |
| 266 | + targetVersion: "1.1.0", | |
| 267 | + method: "boot-service", | |
| 268 | + updateId: "launcher-id", | |
| 269 | + }); | |
| 270 | + }), | |
| 271 | + ); | |
| 272 | + | |
| 239 | 273 | it.effect("blocks new commands after an automatic handoff is accepted", () => |
| 240 | 274 | Effect.gen(function* () { |
| 241 | 275 | const { selfUpdate } = yield* makeHarness(); |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 9 | 9 | import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; |
| 10 | 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 11 | +import * as Cause from "effect/Cause"; | |
| 11 | 12 | import * as Context from "effect/Context"; |
| 12 | 13 | import * as Duration from "effect/Duration"; |
| 13 | 14 | import * as Effect from "effect/Effect"; |
| @@ -243,23 +244,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 229 unmodified lines | ||
| 243 | 244 | ? failWith(error.reason, error) |
| 244 | 245 | : failWith(`Could not prepare t3@${targetVersion}.`, error), |
| 245 | 246 | ), |
| 247 | + Effect.interruptible, | |
| 246 | 248 | ); |
| 247 | 249 | |
| 248 | − if (input.automatic === true) { | |
| 249 | − yield* commandAdmission.withPermits(1)( | |
| 250 | − Effect.gen(function* () { | |
| 251 | − yield* idleCheck.assertIdle.pipe( | |
| 252 | − Effect.tapError((error) => | |
| 253 | − Effect.sync(() => { | |
| 254 | − retryWhenIdleFailure = error.retryWhenIdle === true; | |
| 255 | − }), | |
| 256 | − ), | |
| 257 | − ); | |
| 258 | − yield* Ref.set(automaticHandoffPending, true); | |
| 259 | − }), | |
| 260 | − ); | |
| 261 | − } | |
| 262 | − | |
| 263 | 250 | const activatePreparedUpdate = Effect.gen(function* () { |
| 264 | 251 | yield* reportProgress("installing"); |
| 265 | 252 | const updateId = yield* launcher |
| @@ -282,16 +269,31 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 16 unmodified lines | ||
| 282 | 269 | }); |
| 283 | 270 | return { targetVersion, method: "boot-service" as const, updateId }; |
| 284 | 271 | }); |
| 285 | − return yield* activatePreparedUpdate; | |
| 272 | + if (input.automatic !== true) return yield* activatePreparedUpdate; | |
| 273 | + | |
| 274 | + return yield* commandAdmission | |
| 275 | + .withPermits(1)( | |
| 276 | + Effect.gen(function* () { | |
| 277 | + yield* idleCheck.assertIdle.pipe( | |
| 278 | + Effect.tapError((error) => | |
| 279 | + Effect.sync(() => { | |
| 280 | + retryWhenIdleFailure = error.retryWhenIdle === true; | |
| 281 | + }), | |
| 282 | + ), | |
| 283 | + ); | |
| 284 | + yield* Ref.set(automaticHandoffPending, true); | |
| 285 | + }), | |
| 286 | + ) | |
| 287 | + .pipe(Effect.andThen(activatePreparedUpdate)); | |
| 286 | 288 | }).pipe( |
| 287 | − Effect.onError(() => | |
| 289 | + Effect.onError((cause) => | |
| 288 | 290 | Effect.all( |
| 289 | 291 | [ |
| 290 | 292 | Ref.set(inFlight, false), |
| 291 | 293 | Ref.set(automaticHandoffPending, false), |
| 292 | 294 | Ref.set( |
| 293 | 295 | lastFailedTarget, |
| 294 | − retryWhenIdleFailure | |
| 296 | + retryWhenIdleFailure || Cause.hasInterruptsOnly(cause) | |
| 295 | 297 | ? previousFailedTarget |
| 296 | 298 | : input.automatic === true || |
| 297 | 299 | previousFailedTarget === null || |
| @@ -95,13 +95,23 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 94 unmodified lines | ||
| 95 | 95 | Effect.fn("environment.orchestration.dispatch")(function* (args) { |
| 96 | 96 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 97 | 97 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); |
| 98 | − const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( | |
| 99 | − Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), | |
| 100 | − ); | |
| 101 | 98 | return yield* serverSelfUpdate |
| 102 | − .withCommandAdmission(orchestrationEngine.dispatch(normalizedCommand)) | |
| 99 | + .withCommandAdmission( | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( | |
| 102 | + Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), | |
| 103 | + ); | |
| 104 | + return yield* orchestrationEngine | |
| 105 | + .dispatch(normalizedCommand) | |
| 106 | + .pipe( | |
| 107 | + Effect.catch((cause) => | |
| 108 | + failEnvironmentInternal("orchestration_dispatch_failed", cause), | |
| 109 | + ), | |
| 110 | + ); | |
| 111 | + }), | |
| 112 | + ) | |
| 103 | 113 | .pipe( |
| 104 | − Effect.catch((cause) => | |
| 114 | + Effect.catchTag("ServerSelfUpdateError", (cause) => | |
| 105 | 115 | failEnvironmentInternal("orchestration_dispatch_failed", cause), |
| 106 | 116 | ), |
| 107 | 117 | ); |
| @@ -2130,8 +2130,11 @@ function ChatViewContent(props: ChatViewProps) { · 2129 unmodified lines | ||
| 2130 | 2130 | if ( |
| 2131 | 2131 | serverUpdateEnvironmentId && |
| 2132 | 2132 | !reconnectingThroughVersionSkew && |
| 2133 | − (serverUpdateState.status !== "idle" || | |
| 2134 | − (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) | |
| 2133 | + (serverUpdateState.status === "running" || | |
| 2134 | + serverUpdateState.status === "failed" || | |
| 2135 | + (showVersionMismatchBanner && | |
| 2136 | + (serverUpdateState.status === "pending" || | |
| 2137 | + (versionMismatch && versionMismatchDismissKey)))) | |
| 2135 | 2138 | ) { |
| 2136 | 2139 | const updateInProgress = serverUpdateState.status === "running"; |
| 2137 | 2140 | const updatePending = serverUpdateState.status === "pending"; |
| @@ -2181,6 +2184,7 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines | ||
| 2181 | 2184 | // slot would only repeat it. |
| 2182 | 2185 | actions: |
| 2183 | 2186 | updateInProgress || |
| 2187 | + updatePending || | |
| 2184 | 2188 | !versionMismatch || |
| 2185 | 2189 | versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( |
| 2186 | 2190 | <ServerUpdateAction |
| @@ -1395,10 +1395,13 @@ function AutomaticServerUpdateControl({ · 1394 unmodified lines | ||
| 1395 | 1395 | |
| 1396 | 1396 | if (compact) { |
| 1397 | 1397 | return ( |
| 1398 | − <div className="flex items-center gap-2 pt-1 text-xs text-muted-foreground"> | |
| 1398 | + <label | |
| 1399 | + className="flex w-fit cursor-pointer items-center gap-2 pt-1 text-xs text-muted-foreground" | |
| 1400 | + onClick={(event) => event.stopPropagation()} | |
| 1401 | + > | |
| 1399 | 1402 | {controlWithExplanation} |
| 1400 | 1403 | <span>Auto-update when idle</span> |
| 1401 | − </div> | |
| 1404 | + </label> | |
| 1402 | 1405 | ); |
| 1403 | 1406 | } |
| 1404 | 1407 | |