Unified · Split
| @@ -20,6 +20,7 @@ interface HarnessOptions { · 19 unmodified lines | |||
| 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 automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>; |
| 23 | readonly install?: (order: string[]) => Effect.Effect<void>; | ||
| 23 | readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; | 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 | Effect.gen(function* () { | 36 | Effect.gen(function* () { |
| 36 | if (input.command === "npm") { | 37 | if (input.command === "npm") { |
| 37 | order.push("install"); | 38 | order.push("install"); |
| 39 | yield* options.install?.(order) ?? Effect.void; | ||
| 38 | const prefix = input.args[input.args.indexOf("--prefix") + 1]; | 40 | const prefix = input.args[input.args.indexOf("--prefix") + 1]; |
| 39 | if (prefix === undefined) return yield* Effect.die("missing npm prefix"); | 41 | if (prefix === undefined) return yield* Effect.die("missing npm prefix"); |
| 40 | const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); | 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 | it.effect("blocks new commands after an automatic handoff is accepted", () => | 273 | it.effect("blocks new commands after an automatic handoff is accepted", () => |
| 240 | Effect.gen(function* () { | 274 | Effect.gen(function* () { |
| 241 | const { selfUpdate } = yield* makeHarness(); | 275 | const { selfUpdate } = yield* makeHarness(); |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; | 8 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 9 | import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; | 9 | import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate"; |
| 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; | 10 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 11 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 12 | import * as Duration from "effect/Duration"; | 13 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; | 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 | ? failWith(error.reason, error) | 244 | ? failWith(error.reason, error) |
| 244 | : failWith(`Could not prepare t3@${targetVersion}.`, error), | 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 | const activatePreparedUpdate = Effect.gen(function* () { | 250 | const activatePreparedUpdate = Effect.gen(function* () { |
| 264 | yield* reportProgress("installing"); | 251 | yield* reportProgress("installing"); |
| 265 | const updateId = yield* launcher | 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 | return { targetVersion, method: "boot-service" as const, updateId }; | 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 | }).pipe( | 288 | }).pipe( |
| 287 | Effect.onError(() => | 289 | Effect.onError((cause) => |
| 288 | Effect.all( | 290 | Effect.all( |
| 289 | [ | 291 | [ |
| 290 | Ref.set(inFlight, false), | 292 | Ref.set(inFlight, false), |
| 291 | Ref.set(automaticHandoffPending, false), | 293 | Ref.set(automaticHandoffPending, false), |
| 292 | Ref.set( | 294 | Ref.set( |
| 293 | lastFailedTarget, | 295 | lastFailedTarget, |
| 294 | retryWhenIdleFailure | 296 | retryWhenIdleFailure || Cause.hasInterruptsOnly(cause) |
| 295 | ? previousFailedTarget | 297 | ? previousFailedTarget |
| 296 | : input.automatic === true || | 298 | : input.automatic === true || |
| 297 | previousFailedTarget === null || | 299 | previousFailedTarget === null || |
| @@ -95,13 +95,23 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 94 unmodified lines | |||
| 95 | Effect.fn("environment.orchestration.dispatch")(function* (args) { | 95 | Effect.fn("environment.orchestration.dispatch")(function* (args) { |
| 96 | yield* annotateEnvironmentRequest(args.endpoint.name); | 96 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 97 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); | 97 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); |
| 98 | const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( | ||
| 99 | Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), | ||
| 100 | ); | ||
| 101 | return yield* serverSelfUpdate | 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 | .pipe( | 113 | .pipe( |
| 104 | Effect.catch((cause) => | 114 | Effect.catchTag("ServerSelfUpdateError", (cause) => |
| 105 | failEnvironmentInternal("orchestration_dispatch_failed", cause), | 115 | failEnvironmentInternal("orchestration_dispatch_failed", cause), |
| 106 | ), | 116 | ), |
| 107 | ); | 117 | ); |
| @@ -2130,8 +2130,11 @@ function ChatViewContent(props: ChatViewProps) { · 2129 unmodified lines | |||
| 2130 | if ( | 2130 | if ( |
| 2131 | serverUpdateEnvironmentId && | 2131 | serverUpdateEnvironmentId && |
| 2132 | !reconnectingThroughVersionSkew && | 2132 | !reconnectingThroughVersionSkew && |
| 2133 | (serverUpdateState.status !== "idle" || | 2133 | (serverUpdateState.status === "running" || |
| 2134 | (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) | 2134 | serverUpdateState.status === "failed" || |
| 2135 | (showVersionMismatchBanner && | ||
| 2136 | (serverUpdateState.status === "pending" || | ||
| 2137 | (versionMismatch && versionMismatchDismissKey)))) | ||
| 2135 | ) { | 2138 | ) { |
| 2136 | const updateInProgress = serverUpdateState.status === "running"; | 2139 | const updateInProgress = serverUpdateState.status === "running"; |
| 2137 | const updatePending = serverUpdateState.status === "pending"; | 2140 | const updatePending = serverUpdateState.status === "pending"; |
| @@ -2181,6 +2184,7 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines | |||
| 2181 | // slot would only repeat it. | 2184 | // slot would only repeat it. |
| 2182 | actions: | 2185 | actions: |
| 2183 | updateInProgress || | 2186 | updateInProgress || |
| 2187 | updatePending || | ||
| 2184 | !versionMismatch || | 2188 | !versionMismatch || |
| 2185 | versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( | 2189 | versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( |
| 2186 | <ServerUpdateAction | 2190 | <ServerUpdateAction |
| @@ -1395,10 +1395,13 @@ function AutomaticServerUpdateControl({ · 1394 unmodified lines | |||
| 1395 | 1395 | ||
| 1396 | if (compact) { | 1396 | if (compact) { |
| 1397 | return ( | 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 | {controlWithExplanation} | 1402 | {controlWithExplanation} |
| 1400 | <span>Auto-update when idle</span> | 1403 | <span>Auto-update when idle</span> |
| 1401 | </div> | 1404 | </label> |
| 1402 | ); | 1405 | ); |
| 1403 | } | 1406 | } |
| 1404 | 1407 | ||