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, onHandoffAccepted?: () => Effect.Effect, ) => Effect.Effect; readonly commitDesktopUpdate: ( requestId: string, onHandoffAccepted?: () => Effect.Effect, ) => Effect.Effect; } >()("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, ServerSelfUpdateError>; readonly clear: ( threadIds: ReadonlyArray, ) => Effect.Effect; }) { const desktopContinuationTokens = yield* Ref.make(HashSet.empty()); const clearOnError = ( effect: Effect.Effect, threadIds: () => ReadonlyArray, handoffAccepted: () => boolean, ): Effect.Effect => 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 = []; 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 = []; 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), );