| 1 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 2 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; |
| 3 | import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease"; |
| 4 | |
| 5 | import { |
| 6 | resolveServerSelfUpdateCapability, |
| 7 | resolveVersionMismatch, |
| 8 | supportsDesktopAppUpdate, |
| 9 | supportsServerUpdateThreadContinuation, |
| 10 | } from "~/versionSkew"; |
| 11 | import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction"; |
| 12 | |
| 13 | export 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. */ |
| 30 | export 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 | */ |
| 40 | export 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". */ |
| 67 | export 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 | */ |
| 83 | export 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 | |
| 144 | export 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 | */ |
| 159 | export 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. */ |
| 176 | export type UpdateRunMachineStatus = |
| 177 | | "providers" |
| 178 | | "waiting" |
| 179 | | "downloading" |
| 180 | | "restarting" |
| 181 | | "done" |
| 182 | | "failed"; |
| 183 | |
| 184 | /** One machine in a running "update everything" pass. */ |
| 185 | export 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. */ |
| 195 | export 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. */ |
| 204 | export 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 | |
| 217 | const 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 | */ |
| 231 | export 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. */ |
| 243 | export 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. */ |
| 253 | export 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 | */ |
| 287 | export 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 | |
| 310 | export 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 | |
| 324 | function countLabel(count: number, noun: string): string | null { |
| 325 | return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`; |
| 326 | } |
| 327 | |
| 328 | function 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 | */ |
| 338 | export 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 | } |