1import {
2 ServerSelfUpdateError,
3 type ServerSelfUpdateCapability,
4 type ServerSelfUpdateInput,
5 type ServerSelfUpdateProgressStage,
6 type ServerSelfUpdateResult,
7 type ThreadId,
8} from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Cause from "effect/Cause";
11import * as Config from "effect/Config";
12import * as Context from "effect/Context";
13import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";
15import * as HashSet from "effect/HashSet";
16import * as Option from "effect/Option";
17import * as Ref from "effect/Ref";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import { HttpClient } from "effect/http";
22
23import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";
24
25import * as ServerConfig from "../config.ts";
26import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
27import * as ProcessRunner from "../processRunner.ts";
28import {
29 ensurePinnedRuntimeInstalled,
30 pinnedRuntimeCommand,
31 PinnedRuntimeInstallError,
32 PinnedRuntimePreflightBlockedError,
33} from "./pinnedRuntime.ts";
34import { decodeServicePreflightResult } from "./servicePreflight.ts";
35import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
36import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
37
38const PREFLIGHT_TIMEOUT = Duration.seconds(30);
39
40export function resolveServerSelfUpdateCapability(input: {
41 readonly desktopManaged: boolean;
42 readonly launcherManaged: boolean;
43}): ServerSelfUpdateCapability | null {
44 if (input.desktopManaged) return "desktop-managed" as const;
45 return input.launcherManaged ? ("boot-service" as const) : null;
46}
47
48export class ServerSelfUpdate extends Context.Service<
49 ServerSelfUpdate,
50 {
51 readonly update: (
52 input: ServerSelfUpdateInput,
53 reportProgress?: (
54 stage: ServerSelfUpdateProgressStage,
55 ) => Effect.Effect<void, ServerSelfUpdateError>,
56 onHandoffAccepted?: () => Effect.Effect<void>,
57 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
58 readonly commitDesktopUpdate: (
59 requestId: string,
60 onHandoffAccepted?: () => Effect.Effect<void>,
61 ) => Effect.Effect<never, ServerSelfUpdateError>;
62 }
63>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
64
65export const withRunningThreadContinuation = Effect.fn(
66 "cloud.server_self_update.withRunningThreadContinuation",
67)(function* (input: {
68 readonly mode: ServerConfig.RuntimeMode;
69 readonly selfUpdate: ServerSelfUpdate["Service"];
70 readonly prepare: Effect.Effect<ReadonlyArray<ThreadId>, ServerSelfUpdateError>;
71 readonly clear: (
72 threadIds: ReadonlyArray<ThreadId>,
73 ) => Effect.Effect<void, ServerSelfUpdateError>;
74}) {
75 const desktopContinuationTokens = yield* Ref.make(HashSet.empty<string>());
76 const clearOnError = <A>(
77 effect: Effect.Effect<A, ServerSelfUpdateError>,
78 threadIds: () => ReadonlyArray<ThreadId>,
79 handoffAccepted: () => boolean,
80 ): Effect.Effect<A, ServerSelfUpdateError> =>
81 effect.pipe(
82 Effect.catchCause((cause) =>
83 (handoffAccepted() && Cause.hasInterruptsOnly(cause)
84 ? Effect.void
85 : input.clear(threadIds())
86 ).pipe(Effect.andThen(Effect.failCause(cause))),
87 ),
88 );
89
90 const update: ServerSelfUpdate["Service"]["update"] = (
91 request,
92 reportProgress = () => Effect.void,
93 ) => {
94 let prepared = false;
95 let handoffAccepted = false;
96 let continuationThreadIds: ReadonlyArray<ThreadId> = [];
97 return clearOnError(
98 input.selfUpdate
99 .update(
100 request,
101 (stage) =>
102 (request.continueRunningThreads === true &&
103 input.mode !== "desktop" &&
104 stage === "installing" &&
105 !prepared
106 ? input.prepare.pipe(
107 Effect.tap((threadIds) =>
108 Effect.sync(() => {
109 prepared = true;
110 continuationThreadIds = threadIds;
111 }),
112 ),
113 Effect.asVoid,
114 )
115 : Effect.void
116 ).pipe(Effect.andThen(reportProgress(stage))),
117 () =>
118 Effect.sync(() => {
119 handoffAccepted = true;
120 }),
121 )
122 .pipe(
123 Effect.tap((result) => {
124 if (
125 result.method === "desktop-app" &&
126 result.desktopUpdateToken !== undefined &&
127 request.continueRunningThreads === true
128 ) {
129 return Ref.update(desktopContinuationTokens, HashSet.add(result.desktopUpdateToken));
130 }
131 return Effect.void;
132 }),
133 ),
134 () => continuationThreadIds,
135 () => handoffAccepted,
136 );
137 };
138
139 return ServerSelfUpdate.of({
140 update,
141 commitDesktopUpdate: (requestId) =>
142 Effect.gen(function* () {
143 const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [
144 HashSet.has(tokens, requestId),
145 HashSet.remove(tokens, requestId),
146 ]);
147 let handoffAccepted = false;
148 let continuationThreadIds: ReadonlyArray<ThreadId> = [];
149 return yield* clearOnError(
150 Effect.gen(function* () {
151 continuationThreadIds = shouldContinue ? yield* input.prepare : [];
152 return yield* input.selfUpdate.commitDesktopUpdate(requestId, () =>
153 Effect.sync(() => {
154 handoffAccepted = true;
155 }),
156 );
157 }),
158 () => continuationThreadIds,
159 () => handoffAccepted,
160 ).pipe(
161 Effect.catchCause((cause) =>
162 (shouldContinue && !handoffAccepted
163 ? Ref.update(desktopContinuationTokens, HashSet.add(requestId))
164 : Effect.void
165 ).pipe(Effect.andThen(Effect.failCause(cause))),
166 ),
167 );
168 }),
169 });
170});
171
172export const make = Effect.fn("cloud.server_self_update.make")(function* () {
173 const serverConfig = yield* ServerConfig.ServerConfig;
174 const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
175 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
176 const runner = yield* ProcessRunner.ProcessRunner;
177 const fs = yield* FileSystem.FileSystem;
178 const path = yield* Path.Path;
179 const platform = yield* HostProcess.Platform;
180 const arch = yield* HostProcess.Architecture;
181 // Archive-distributed targets download from GitHub Releases. The client is
182 // optional so callers without one (tests, npm-only hosts) still construct.
183 const httpClient = yield* HttpClient.HttpClient;
184 const releaseBaseUrl = Option.getOrUndefined(
185 yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
186 );
187 const inFlight = yield* Ref.make(false);
188
189 const capability: ServerSelfUpdateCapability | null =
190 serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
191 const failWith = (reason: string, cause?: unknown) =>
192 cause === undefined
193 ? new ServerSelfUpdateError({ reason })
194 : new ServerSelfUpdateError({ reason, cause });
195
196 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
197 "cloud.server_self_update.update",
198 )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
199 if (capability === "desktop-managed") {
200 // input.targetVersion is meaningless here: the desktop app's own
201 // update feed decides what it downloads, and the result carries what
202 // it actually got.
203 if (desktopAppUpdate.available) {
204 return yield* desktopAppUpdate.run(reportProgress);
205 }
206 return yield* failWith(
207 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
208 );
209 }
210 if (capability === null) {
211 return yield* failWith(
212 "Remote updates require the T3 Code background service. Run `t3 service install` on the server machine.",
213 );
214 }
215
216 const targetVersion = input.targetVersion.trim();
217 if (!isExactServiceVersion(targetVersion)) {
218 return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);
219 }
220 if (yield* Ref.getAndSet(inFlight, true)) {
221 return yield* failWith("A server update is already in progress.");
222 }
223
224 return yield* Effect.gen(function* () {
225 yield* reportProgress("downloading");
226 const paths = yield* ensurePinnedRuntimeInstalled({
227 baseDir: serverConfig.baseDir,
228 version: targetVersion,
229 fs,
230 path,
231 runner,
232 httpClient,
233 platform,
234 arch,
235 releaseBaseUrl,
236 validate: (runtime) =>
237 runner
238 .run({
239 command: pinnedRuntimeCommand(runtime).command,
240 args: [
241 ...pinnedRuntimeCommand(runtime).args,
242 "__service-preflight",
243 "--database-path",
244 serverConfig.dbPath,
245 "--launcher-protocol",
246 String(SERVICE_LAUNCHER_PROTOCOL),
247 ],
248 timeout: PREFLIGHT_TIMEOUT,
249 })
250 .pipe(
251 Effect.mapError(
252 (cause) =>
253 new PinnedRuntimeInstallError({
254 step: "running the staged service preflight",
255 cause,
256 }),
257 ),
258 Effect.flatMap(
259 (
260 result,
261 ): Effect.Effect<
262 void,
263 PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
264 > => {
265 if (result.code !== 0) {
266 return Effect.fail(
267 new PinnedRuntimeInstallError({
268 step: "running the staged service preflight",
269 exitCode: Number(result.code),
270 stdoutLength: result.stdout.length,
271 stderrLength: result.stderr.length,
272 }),
273 );
274 }
275 let parsed: unknown;
276 try {
277 parsed = JSON.parse(result.stdout.trim());
278 } catch (cause) {
279 return Effect.fail(
280 new PinnedRuntimeInstallError({
281 step: "decoding the staged service preflight",
282 cause,
283 }),
284 );
285 }
286 const preflight = decodeServicePreflightResult(parsed);
287 if (preflight === undefined || preflight.version !== targetVersion) {
288 return Effect.fail(
289 new PinnedRuntimeInstallError({
290 step: "verifying the staged service preflight",
291 }),
292 );
293 }
294 return preflight.status === "ready"
295 ? Effect.void
296 : Effect.fail(
297 new PinnedRuntimePreflightBlockedError({
298 version: targetVersion,
299 reason: preflight.reason,
300 }),
301 );
302 },
303 ),
304 ),
305 }).pipe(
306 Effect.mapError((error) =>
307 error._tag === "PinnedRuntimePreflightBlockedError"
308 ? failWith(error.reason, error)
309 : failWith(`Could not prepare t3@${targetVersion}.`, error),
310 ),
311 );
312
313 yield* reportProgress("installing");
314 const updateId = yield* Effect.uninterruptible(
315 launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe(
316 Effect.mapError((error) =>
317 failWith(
318 error._tag === "ServiceLauncherRejectedError"
319 ? error.reason
320 : "Could not ask the service launcher to activate the prepared update.",
321 error,
322 ),
323 ),
324 Effect.tap(() => onHandoffAccepted()),
325 ),
326 );
327
328 yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", {
329 updateId,
330 targetVersion,
331 runtimePath: paths.entryPath,
332 });
333 return { targetVersion, method: "boot-service" as const, updateId };
334 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
335 });
336
337 return ServerSelfUpdate.of({
338 update,
339 commitDesktopUpdate: (requestId, onHandoffAccepted) =>
340 desktopAppUpdate.commit(requestId, onHandoffAccepted),
341 });
342});
343
344export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
345 Layer.provide(ProcessRunner.layer),
346);