329 lines · 11.8 KB · TypeScript
RawDownload
import {  ServerSelfUpdateError,  type ServerSelfUpdateCapability,  type ServerSelfUpdateInput,  type ServerSelfUpdateProgressStage,  type ServerSelfUpdateResult,  type ThreadId,} from "@t3tools/contracts";import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as HashSet from "effect/HashSet";import * as Ref from "effect/Ref";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts";import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";import * as ProcessRunner from "../processRunner.ts";import {  ensurePinnedRuntimeInstalled,  PinnedRuntimeInstallError,  PinnedRuntimePreflightBlockedError,} from "./pinnedRuntime.ts";import { decodeServicePreflightResult } from "./servicePreflight.ts";import * as ServiceLauncherClient from "./serviceLauncherClient.ts";import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; const PREFLIGHT_TIMEOUT = Duration.seconds(30); export function resolveServerSelfUpdateCapability(input: {  readonly desktopManaged: boolean;  readonly launcherManaged: boolean;}): ServerSelfUpdateCapability | null {  if (input.desktopManaged) return "desktop-managed" as const;  return input.launcherManaged ? ("boot-service" as const) : null;} export class ServerSelfUpdate extends Context.Service<  ServerSelfUpdate,  {    readonly update: (      input: ServerSelfUpdateInput,      reportProgress?: (        stage: ServerSelfUpdateProgressStage,      ) => Effect.Effect<void, ServerSelfUpdateError>,      onHandoffAccepted?: () => Effect.Effect<void>,    ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;    readonly commitDesktopUpdate: (      requestId: string,      onHandoffAccepted?: () => Effect.Effect<void>,    ) => Effect.Effect<never, ServerSelfUpdateError>;  }>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} export const withRunningThreadContinuation = Effect.fn(  "cloud.server_self_update.withRunningThreadContinuation",)(function* (input: {  readonly mode: ServerConfig.RuntimeMode;  readonly selfUpdate: ServerSelfUpdate["Service"];  readonly prepare: Effect.Effect<ReadonlyArray<ThreadId>, ServerSelfUpdateError>;  readonly clear: (    threadIds: ReadonlyArray<ThreadId>,  ) => Effect.Effect<void, ServerSelfUpdateError>;}) {  const desktopContinuationTokens = yield* Ref.make(HashSet.empty<string>());  const clearOnError = <A>(    effect: Effect.Effect<A, ServerSelfUpdateError>,    threadIds: () => ReadonlyArray<ThreadId>,    handoffAccepted: () => boolean,  ): Effect.Effect<A, ServerSelfUpdateError> =>    effect.pipe(      Effect.catchCause((cause) =>        (handoffAccepted() && Cause.hasInterruptsOnly(cause)          ? Effect.void          : input.clear(threadIds())        ).pipe(Effect.andThen(Effect.failCause(cause))),      ),    );   const update: ServerSelfUpdate["Service"]["update"] = (    request,    reportProgress = () => Effect.void,  ) => {    let prepared = false;    let handoffAccepted = false;    let continuationThreadIds: ReadonlyArray<ThreadId> = [];    return clearOnError(      input.selfUpdate        .update(          request,          (stage) =>            (request.continueRunningThreads === true &&            input.mode !== "desktop" &&            stage === "installing" &&            !prepared              ? input.prepare.pipe(                  Effect.tap((threadIds) =>                    Effect.sync(() => {                      prepared = true;                      continuationThreadIds = threadIds;                    }),                  ),                  Effect.asVoid,                )              : Effect.void            ).pipe(Effect.andThen(reportProgress(stage))),          () =>            Effect.sync(() => {              handoffAccepted = true;            }),        )        .pipe(          Effect.tap((result) => {            if (              result.method === "desktop-app" &&              result.desktopUpdateToken !== undefined &&              request.continueRunningThreads === true            ) {              return Ref.update(desktopContinuationTokens, HashSet.add(result.desktopUpdateToken));            }            return Effect.void;          }),        ),      () => continuationThreadIds,      () => handoffAccepted,    );  };   return ServerSelfUpdate.of({    update,    commitDesktopUpdate: (requestId) =>      Effect.gen(function* () {        const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [          HashSet.has(tokens, requestId),          HashSet.remove(tokens, requestId),        ]);        let handoffAccepted = false;        let continuationThreadIds: ReadonlyArray<ThreadId> = [];        return yield* clearOnError(          Effect.gen(function* () {            continuationThreadIds = shouldContinue ? yield* input.prepare : [];            return yield* input.selfUpdate.commitDesktopUpdate(requestId, () =>              Effect.sync(() => {                handoffAccepted = true;              }),            );          }),          () => continuationThreadIds,          () => handoffAccepted,        ).pipe(          Effect.catchCause((cause) =>            (shouldContinue && !handoffAccepted              ? Ref.update(desktopContinuationTokens, HashSet.add(requestId))              : Effect.void            ).pipe(Effect.andThen(Effect.failCause(cause))),          ),        );      }),  });}); export const make = Effect.fn("cloud.server_self_update.make")(function* () {  const serverConfig = yield* ServerConfig.ServerConfig;  const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;  const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;  const runner = yield* ProcessRunner.ProcessRunner;  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const execPath = yield* HostProcessExecutablePath;  const inFlight = yield* Ref.make(false);   const capability: ServerSelfUpdateCapability | null =    serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;  const failWith = (reason: string, cause?: unknown) =>    cause === undefined      ? new ServerSelfUpdateError({ reason })      : new ServerSelfUpdateError({ reason, cause });   const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(    "cloud.server_self_update.update",  )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {    if (capability === "desktop-managed") {      // input.targetVersion is meaningless here: the desktop app's own      // update feed decides what it downloads, and the result carries what      // it actually got.      if (desktopAppUpdate.available) {        return yield* desktopAppUpdate.run(reportProgress);      }      return yield* failWith(        "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",      );    }    if (capability === null) {      return yield* failWith(        "Remote updates require the T3 Code background service. Run `t3 service install` on the server machine.",      );    }     const targetVersion = input.targetVersion.trim();    if (!isExactServiceVersion(targetVersion)) {      return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);    }    if (yield* Ref.getAndSet(inFlight, true)) {      return yield* failWith("A server update is already in progress.");    }     return yield* Effect.gen(function* () {      yield* reportProgress("downloading");      const paths = yield* ensurePinnedRuntimeInstalled({        baseDir: serverConfig.baseDir,        version: targetVersion,        fs,        path,        runner,        validate: (runtime) =>          runner            .run({              command: execPath,              args: [                runtime.entryPath,                "__service-preflight",                "--database-path",                serverConfig.dbPath,                "--launcher-protocol",                String(SERVICE_LAUNCHER_PROTOCOL),              ],              timeout: PREFLIGHT_TIMEOUT,            })            .pipe(              Effect.mapError(                (cause) =>                  new PinnedRuntimeInstallError({                    step: "running the staged service preflight",                    cause,                  }),              ),              Effect.flatMap(                (                  result,                ): Effect.Effect<                  void,                  PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError                > => {                  if (result.code !== 0) {                    return Effect.fail(                      new PinnedRuntimeInstallError({                        step: "running the staged service preflight",                        exitCode: Number(result.code),                        stdoutLength: result.stdout.length,                        stderrLength: result.stderr.length,                      }),                    );                  }                  let parsed: unknown;                  try {                    parsed = JSON.parse(result.stdout.trim());                  } catch (cause) {                    return Effect.fail(                      new PinnedRuntimeInstallError({                        step: "decoding the staged service preflight",                        cause,                      }),                    );                  }                  const preflight = decodeServicePreflightResult(parsed);                  if (preflight === undefined || preflight.version !== targetVersion) {                    return Effect.fail(                      new PinnedRuntimeInstallError({                        step: "verifying the staged service preflight",                      }),                    );                  }                  return preflight.status === "ready"                    ? Effect.void                    : Effect.fail(                        new PinnedRuntimePreflightBlockedError({                          version: targetVersion,                          reason: preflight.reason,                        }),                      );                },              ),            ),      }).pipe(        Effect.mapError((error) =>          error._tag === "PinnedRuntimePreflightBlockedError"            ? failWith(error.reason, error)            : failWith(`Could not prepare t3@${targetVersion}.`, error),        ),      );       yield* reportProgress("installing");      const updateId = yield* Effect.uninterruptible(        launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe(          Effect.mapError((error) =>            failWith(              error._tag === "ServiceLauncherRejectedError"                ? error.reason                : "Could not ask the service launcher to activate the prepared update.",              error,            ),          ),          Effect.tap(() => onHandoffAccepted()),        ),      );       yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", {        updateId,        targetVersion,        runtimePath: paths.entryPath,      });      return { targetVersion, method: "boot-service" as const, updateId };    }).pipe(Effect.onError(() => Ref.set(inFlight, false)));  });   return ServerSelfUpdate.of({    update,    commitDesktopUpdate: (requestId, onHandoffAccepted) =>      desktopAppUpdate.commit(requestId, onHandoffAccepted),  });}); export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(  Layer.provide(ProcessRunner.layer),);