1import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
3import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease";
4
5import {
6 resolveServerSelfUpdateCapability,
7 resolveVersionMismatch,
8 supportsDesktopAppUpdate,
9 supportsServerUpdateThreadContinuation,
10} from "~/versionSkew";
11import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction";
12
13export interface UpdateEverythingMachine {
14 readonly environmentId: EnvironmentId;
15 readonly label: string;
16 readonly serverConfig: {
17 readonly environment: ServerConfig["environment"];
18 readonly settings: Pick<ServerConfig["settings"], "continueThreadsAfterServerUpdate">;
19 } | null;
20 readonly connected: boolean;
21 /** Served by the desktop app this client runs in (the primary backend or a
22 local WSL backend). Its server updates when the app installs. */
23 readonly hostedByThisApp: boolean;
24 /** The session holds the scope a server update needs. */
25 readonly canMaintain: boolean;
26 readonly updateState: ServerUpdateState;
27}
28
29/** A server that restarts during a run. */
30export interface RestartingMachine {
31 readonly environmentId: EnvironmentId;
32 /** The server resumes interrupted threads after it restarts. */
33 readonly continuesThreads: boolean;
34}
35
36/**
37 * The servers a run restarts: each server it updates, and the servers this app
38 * hosts when this app installs. Provider updates restart nothing.
39 */
40export function collectRestartingMachines(
41 machines: ReadonlyArray<UpdateEverythingMachine>,
42 servers: ReadonlyArray<Pick<ServerUpdateTarget, "environmentId">>,
43 installsLocal: boolean,
44): RestartingMachine[] {
45 const updated = new Set(servers.map((server) => server.environmentId));
46 return machines.flatMap((machine) => {
47 const config = machine.serverConfig;
48 if (
49 config === null ||
50 !machine.connected ||
51 !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp))
52 ) {
53 return [];
54 }
55 return [
56 {
57 environmentId: machine.environmentId,
58 continuesThreads:
59 config.environment.capabilities.threadRestartContinuation === true &&
60 config.settings.continueThreadsAfterServerUpdate === true,
61 },
62 ];
63 });
64}
65
66/** A machine the run leaves alone, with a short reason such as "not connected". */
67export interface SkippedMachine {
68 readonly environmentId: EnvironmentId;
69 readonly label: string;
70 readonly reason: string;
71}
72
73/**
74 * The servers that "update everything" moves to `targetVersion`: connected,
75 * permitted, able to update over RPC, behind the target, and on its release
76 * channel. Every other behind server comes back in `skipped` with a reason for
77 * the confirmation; a machine that never connected has an unknown version, so
78 * it counts as skipped. Two kinds are in neither list: servers already on the
79 * target, and servers this app hosts, which would relaunch the app mid-run
80 * and update with the app's own install anyway. A desktop-managed server
81 * follows its own app's update feed, so the channel rule does not apply to it.
82 */
83export function collectServerUpdateTargets(
84 machines: ReadonlyArray<UpdateEverythingMachine>,
85 targetVersion: string,
86): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } {
87 const targets: ServerUpdateTarget[] = [];
88 const skipped: SkippedMachine[] = [];
89 for (const machine of machines) {
90 const config = machine.serverConfig;
91 if (machine.hostedByThisApp) continue;
92 if (
93 config !== null &&
94 resolveVersionMismatch(config.environment.serverVersion, targetVersion) === null
95 ) {
96 continue;
97 }
98 const skip = (reason: string) =>
99 skipped.push({ environmentId: machine.environmentId, label: machine.label, reason });
100 if (config === null || !machine.connected) {
101 skip("not connected");
102 continue;
103 }
104 if (!machine.canMaintain) {
105 skip("no permission");
106 continue;
107 }
108 if (machine.updateState.status === "running") {
109 skip("already updating");
110 continue;
111 }
112 const selfUpdate = resolveServerSelfUpdateCapability(config);
113 const desktopAppUpdate = supportsDesktopAppUpdate(config);
114 if (selfUpdate === null) {
115 skip("needs a manual update");
116 continue;
117 }
118 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
119 skip("needs a newer desktop app");
120 continue;
121 }
122 const serverVersion = config.environment.serverVersion;
123 if (
124 selfUpdate !== "desktop-managed" &&
125 cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion)
126 ) {
127 skip(`on the ${cliReleaseChannelOf(serverVersion)} channel`);
128 continue;
129 }
130 targets.push({
131 environmentId: machine.environmentId,
132 serverLabel: machine.label,
133 selfUpdate,
134 installation: config.environment.capabilities.serverInstallation,
135 desktopAppUpdate,
136 threadContinuation: supportsServerUpdateThreadContinuation(config),
137 continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false,
138 targetVersion,
139 });
140 }
141 return { targets, skipped };
142}
143
144export interface UpdateEverythingSteps<Providers, Servers> {
145 /** Downloads this app's update. Resolves true when it is ready to install.
146 Without it, `localReady` is true: there is nothing to download. */
147 readonly downloadLocal?: () => Promise<boolean>;
148 readonly updateProviders: () => Promise<Providers>;
149 readonly updateServers: () => Promise<Servers>;
150}
151
152/**
153 * Runs the remote half of one "update everything" pass and returns each
154 * step's results. Providers finish before any server restarts, so no install
155 * is cut off. The local download runs alongside the remote work. The caller
156 * installs this app afterwards when `localReady`, because the relaunch ends
157 * the run; a failed download skips only that install.
158 */
159export async function runUpdateEverything<Providers, Servers>(
160 steps: UpdateEverythingSteps<Providers, Servers>,
161): Promise<{
162 readonly localReady: boolean;
163 readonly providers: Providers;
164 readonly servers: Servers;
165}> {
166 const [localReady, remote] = await Promise.all([
167 steps.downloadLocal?.() ?? Promise.resolve(true),
168 steps
169 .updateProviders()
170 .then(async (providers) => ({ providers, servers: await steps.updateServers() })),
171 ]);
172 return { localReady, ...remote };
173}
174
175/** The current state of one machine in a running pass. */
176export type UpdateRunMachineStatus =
177 | "providers"
178 | "waiting"
179 | "downloading"
180 | "restarting"
181 | "done"
182 | "failed";
183
184/** One machine in a running "update everything" pass. */
185export interface UpdateRunMachine {
186 readonly environmentId: EnvironmentId;
187 readonly label: string;
188 readonly updatesProviders: boolean;
189 readonly updatesServer: boolean;
190 /** Set when every update for this machine has finished. */
191 readonly result: "done" | "failed" | null;
192}
193
194/** A running "update everything" pass, kept while it runs. */
195export interface UpdateEverythingRun {
196 /** The version this app installs last, or null when it does not install. */
197 readonly localVersion: string | null;
198 /** Providers update first. Servers update after every provider finished. */
199 readonly phase: "providers" | "servers" | "installing";
200 readonly machines: ReadonlyArray<UpdateRunMachine>;
201}
202
203/** Status of one machine, from the run and that server's own update state. */
204export function resolveUpdateRunMachineStatus(
205 machine: UpdateRunMachine,
206 phase: UpdateEverythingRun["phase"],
207 serverState: ServerUpdateState,
208): UpdateRunMachineStatus {
209 if (machine.result !== null) return machine.result;
210 if (phase === "providers" && machine.updatesProviders) return "providers";
211 if (machine.updatesServer && serverState.status === "running") {
212 return serverState.stage === "resuming" ? "restarting" : "downloading";
213 }
214 return "waiting";
215}
216
217const RUN_STATUS_LABELS: Record<UpdateRunMachineStatus, string> = {
218 providers: "updating providers",
219 waiting: "waiting",
220 downloading: "downloading",
221 restarting: "restarting",
222 done: "done",
223 failed: "failed",
224};
225
226/**
227 * Tooltip lines for a running pass, such as "alvin: restarting". `local` is
228 * this app's own state, such as "downloading 40%", or null when it does not
229 * install.
230 */
231export function describeUpdateRun(
232 machines: ReadonlyArray<{ readonly label: string; readonly status: UpdateRunMachineStatus }>,
233 local: string | null,
234): ReadonlyArray<string> {
235 return [
236 "Updating…",
237 ...(local === null ? [] : [`This app: ${local}`]),
238 ...machines.map((machine) => `${machine.label}: ${RUN_STATUS_LABELS[machine.status]}`),
239 ];
240}
241
242/** What one pass did. It is saved before this app installs, so it outlives the relaunch. */
243export interface UpdateEverythingResult {
244 /** The version this app installed last, or null when it did not install. */
245 readonly localVersion: string | null;
246 readonly updatedServers: number;
247 readonly updatedProviders: number;
248 /** One line per failure, each naming its machine. */
249 readonly failures: ReadonlyArray<string>;
250}
251
252/** Counts what a pass updated and lists each failure with its machine. */
253export function collectUpdateEverythingResult(input: {
254 readonly localVersion: string | null;
255 /** This app's own download or install failures. */
256 readonly localFailures: ReadonlyArray<string>;
257 readonly servers: ReadonlyArray<{
258 readonly label: string;
259 readonly outcome: ServerUpdateOutcome;
260 }>;
261 readonly providers: ReadonlyArray<
262 | { readonly status: "interrupted" | "updated" }
263 | { readonly status: "failed"; readonly line: string }
264 >;
265}): UpdateEverythingResult {
266 return {
267 localVersion: input.localVersion,
268 updatedServers: input.servers.filter(({ outcome }) => outcome.status === "updated").length,
269 updatedProviders: input.providers.filter((outcome) => outcome.status === "updated").length,
270 failures: [
271 ...input.localFailures.map((failure) => `This app: ${failure}`),
272 ...input.servers.flatMap(({ label, outcome }) =>
273 outcome.status === "failed" ? [`${label}: ${outcome.message}`] : [],
274 ),
275 ...input.providers.flatMap((outcome) => (outcome.status === "failed" ? [outcome.line] : [])),
276 ],
277 };
278}
279
280/**
281 * The one toast for a finished pass, such as "Updated this app, 8 servers,
282 * and 3 providers" with a line per failure. `appVersion` is the version this
283 * app runs now: after the relaunch it tells whether the local install
284 * applied. Null when nothing finished, for example every request was
285 * interrupted.
286 */
287export function getUpdateEverythingToastView(
288 result: UpdateEverythingResult,
289 appVersion: string,
290): {
291 readonly type: "success" | "error";
292 readonly title: string;
293 readonly description?: string;
294} | null {
295 const parts = [
296 result.localVersion !== null && result.localVersion === appVersion ? "this app" : null,
297 countLabel(result.updatedServers, "server"),
298 countLabel(result.updatedProviders, "provider"),
299 ].filter((part) => part !== null);
300 if (result.failures.length === 0) {
301 return parts.length === 0 ? null : { type: "success", title: `Updated ${joinList(parts)}` };
302 }
303 return {
304 type: "error",
305 title: parts.length === 0 ? "Update failed" : `Updated ${joinList(parts)}`,
306 description: result.failures.join("\n"),
307 };
308}
309
310export interface UpdateEverythingPlan {
311 /** The version this desktop app installs, or null when it is not updating. */
312 readonly localVersion: string | null;
313 readonly servers: ReadonlyArray<
314 Pick<ServerUpdateTarget, "environmentId" | "serverLabel" | "selfUpdate" | "targetVersion">
315 >;
316 readonly providerMachines: ReadonlyArray<{
317 readonly environmentId: EnvironmentId;
318 readonly label: string;
319 /** Display names of the outdated providers on that machine. */
320 readonly providers: ReadonlyArray<string>;
321 }>;
322}
323
324function countLabel(count: number, noun: string): string | null {
325 return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`;
326}
327
328function joinList(parts: ReadonlyArray<string>): string {
329 if (parts.length <= 2) return parts.join(" and ");
330 return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
331}
332
333/**
334 * Text for the sidebar tooltip and the confirmation. `summary` reads like
335 * "Update this app, 2 servers, and 3 providers". `lines` has one entry per
336 * machine, this app first, such as "alvin: server to 0.0.50, Codex, Claude".
337 */
338export function describeUpdateEverything(plan: UpdateEverythingPlan): {
339 readonly summary: string;
340 readonly lines: ReadonlyArray<string>;
341} {
342 const providerCount = plan.providerMachines.reduce(
343 (count, machine) => count + machine.providers.length,
344 0,
345 );
346 const summary = `Update ${joinList(
347 [
348 plan.localVersion ? "this app" : null,
349 countLabel(plan.servers.length, "server"),
350 countLabel(providerCount, "provider"),
351 ].filter((part) => part !== null),
352 )}`;
353
354 const machines = new Map<EnvironmentId, { label: string; items: string[] }>();
355 const itemsFor = (environmentId: EnvironmentId, label: string) => {
356 const existing = machines.get(environmentId);
357 if (existing) return existing.items;
358 const items: string[] = [];
359 machines.set(environmentId, { label, items });
360 return items;
361 };
362 for (const server of plan.servers) {
363 // A desktop-managed server installs whatever its own app's update feed
364 // offers, so it has no target version to show.
365 itemsFor(server.environmentId, server.serverLabel).push(
366 server.selfUpdate === "desktop-managed"
367 ? "desktop app update"
368 : `server to ${server.targetVersion}`,
369 );
370 }
371 for (const machine of plan.providerMachines) {
372 itemsFor(machine.environmentId, machine.label).push(...machine.providers);
373 }
374
375 return {
376 summary,
377 lines: [
378 ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []),
379 ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`),
380 ],
381 };
382}