diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index fe435b5833efba21036d46bf902c6a0d5dd214df..8d01d2e7578e2a5b227cca4ffae0119c6b3891ad 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; /** - * Updates every outdated provider on every connected machine at once, then - * reports the results in one toast. Each server queues updates that share an - * installer, so sending them all together is safe. The server still checks - * permissions: a session that cannot operate a machine gets a failure line for - * it. Renders nothing when no machine has a one-click update. + * Every outdated provider with a one-click update on every connected machine, + * plus a function that updates them all and reports the results in one toast. + * Each server queues updates that share an installer, so sending them all + * together is safe. The server still checks permissions: a session that + * cannot operate a machine gets a failure line for it. The returned promise + * settles when every update has finished. */ -export function ProviderUpdatesAction() { +export function useProviderUpdates() { const { environments } = useEnvironments(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); - const pending = useRef(false); - const [isPending, setIsPending] = useState(false); const machines = useMemo( () => environments.flatMap((environment) => { @@ -44,6 +43,41 @@ export function ProviderUpdatesAction() { }), [environments], ); + + const updateAll = async () => { + const runs = await Promise.all( + machines.flatMap(({ environmentId, label, candidates }) => + candidates.map(async (candidate): Promise => ({ + machineLabel: label, + driver: candidate.driver, + instanceId: candidate.instanceId, + result: await updateProvider({ + environmentId, + input: { provider: candidate.driver, instanceId: candidate.instanceId }, + }), + })), + ), + ); + const view = getProviderUpdateRunToastView(runs); + if (view) { + toastManager.add( + stackedThreadToast({ + ...view, + description: {view.description}, + }), + ); + } + }; + + return { machines, updateAll }; +} + +/** Provider settings button for {@link useProviderUpdates}. Renders nothing + when no machine has a one-click update. */ +export function ProviderUpdatesAction() { + const { machines, updateAll } = useProviderUpdates(); + const pending = useRef(false); + const [isPending, setIsPending] = useState(false); // Candidates leave the list as soon as their servers report them queued, so // keep the button while the run is in flight. if (machines.length === 0 && !isPending) { @@ -55,28 +89,7 @@ export function ProviderUpdatesAction() { pending.current = true; setIsPending(true); try { - const runs = await Promise.all( - machines.flatMap(({ environmentId, label, candidates }) => - candidates.map(async (candidate): Promise => ({ - machineLabel: label, - driver: candidate.driver, - instanceId: candidate.instanceId, - result: await updateProvider({ - environmentId, - input: { provider: candidate.driver, instanceId: candidate.instanceId }, - }), - })), - ), - ); - const view = getProviderUpdateRunToastView(runs); - if (view) { - toastManager.add( - stackedThreadToast({ - ...view, - description: {view.description}, - }), - ); - } + await updateAll(); } finally { pending.current = false; setIsPending(false); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index e40d4f5d0792d2d1af1c39a6a210664328ceb76a..0f103235ce64805a3826b4833aeecedccc9d1d93 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -65,7 +65,9 @@ type UpdateButtonProps = Pick, "variant" | "size" readonly appearance?: "button" | "icon"; }; -function useServerUpdate() { +/** Updates one server and waits until it is back on the target version. + Reports its own success or failure toast; never rejects. */ +export function useServerUpdate() { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { const { environmentId, serverLabel, selfUpdate, targetVersion } = target; @@ -164,7 +166,9 @@ export function ServerUpdatesAction({ ); } -function canUpdateServer(result: AsyncResult.AsyncResult): boolean { +export function canUpdateServer( + result: AsyncResult.AsyncResult, +): boolean { if (result._tag !== "Success" || !result.value.authenticated) return false; return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); } diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 04ee884bbf6c2a9cd7088b6d5c0651ad2dd98ac3..c995a23f3463000b27594692878878f9cd91b691 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,5 +1,5 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; +import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; const DOWNLOAD_PROGRESS_RADIUS = 14; @@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState = | "checking" | "available" | "downloading" - | "downloaded"; + | "downloaded" + // Other machines or providers have updates. Matches the server update icon + // in Connections settings; the download arrow stays for this app's own file. + | "everything"; function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { if (percent === null || !Number.isFinite(percent)) return 0; @@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({ return ; } if (status === "downloaded") return ; + if (status === "everything") return ; return ( ["onOpenChange"]> @@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( function resolveSidebarUpdatePresentation({ action, + showsEverything, isDownloading, showCheckIcon, }: { readonly action: ReturnType; + /** Remote work is waiting or running, and this app is not downloading. */ + readonly showsEverything: boolean; readonly isDownloading: boolean; readonly showCheckIcon: boolean; }) { - const showUpdateDetails = action !== "none" || isDownloading; - const iconStatus = showCheckIcon + const showUpdateDetails = action !== "none" || showsEverything || isDownloading; + const iconStatus: DesktopUpdateStatusIconState = showCheckIcon ? "checking" - : action === "install" - ? "downloaded" - : isDownloading - ? "downloading" - : action === "download" - ? "available" - : "idle"; + : isDownloading + ? "downloading" + : showsEverything + ? "everything" + : action === "install" + ? "downloaded" + : action === "download" + ? "available" + : "idle"; return { iconStatus, @@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() { ); } +/** Downloads this app's update. Resolves true when it is ready to install; + failures toast and resolve false. */ +async function downloadLocalUpdate( + bridge: DesktopBridge, + { notifyDownloaded }: { readonly notifyDownloaded: boolean }, +): Promise { + try { + const result = await bridge.downloadUpdate(); + if (result.completed && notifyDownloaded) { + showDesktopUpdateDownloadedToast(bridge, result.state); + } + const actionError = shouldToastDesktopUpdateActionResult(result) + ? getDesktopUpdateActionError(result) + : null; + if (actionError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + } + return result.completed; + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + return false; + } +} + +/** Installs this app's downloaded update, which relaunches the app. + Failures toast. A refused install (the download is not marked ready yet) + leaves the restart to the user. */ +async function installLocalUpdate(bridge: DesktopBridge): Promise { + try { + const result = await bridge.installUpdate(); + if (!result.accepted) { + showDesktopUpdateDownloadedToast(bridge, result.state); + return; + } + const actionError = shouldToastDesktopUpdateActionResult(result) + ? getDesktopUpdateActionError(result) + : null; + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } +} + +/** + * The sidebar update button. With only a local app update it downloads, then + * installs. When other machines' servers or any providers are behind, one + * click updates everything: providers, then servers, then this app last. On + * web there is no local app, so the button shows only for remote updates. + */ export function SidebarUpdatePill() { - return isElectron ? : null; + return ; } function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); + const [isUpdatingEverything, setIsUpdatingEverything] = useState(false); + const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); @@ -133,6 +218,13 @@ function SidebarUpdateControl() { const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; + // Remote servers follow this app to the version it is about to install. + const localUpdateVersion = + state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; + const everything = useUpdateEverything(localUpdateVersion); + const runsEverything = everything.hasUpdates && !isDownloading; + // Providers leave the list once queued, so the run keeps its own state. + const isRunningEverything = isUpdatingEverything && !isDownloading; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ isAnimationLatched: isCheckAnimationLatched, isChecking: state?.status === "checking", @@ -140,21 +232,28 @@ function SidebarUpdateControl() { }); const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, + showsEverything: runsEverything || isRunningEverything, isDownloading, showCheckIcon, }); - const tooltip = showUpdateDetails - ? state - ? getDesktopUpdateButtonTooltip(state) - : "Update available" - : showCheckIcon - ? "Checking for updates…" - : "Check for updates"; + const tooltip = isRunningEverything + ? "Updating…" + : runsEverything + ? everything.summary + : showUpdateDetails + ? state + ? getDesktopUpdateButtonTooltip(state) + : "Update available" + : showCheckIcon + ? "Checking for updates…" + : "Check for updates"; const disabled = showCheckIcon ? true - : showUpdateDetails - ? isDesktopUpdateButtonDisabled(state) - : !canCheckForUpdate(state); + : runsEverything + ? false + : showUpdateDetails + ? isDesktopUpdateButtonDisabled(state) + : !canCheckForUpdate(state); const isInteractionDisabled = disabled || isActionPending; const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( showUpdateDetails, @@ -175,39 +274,20 @@ function SidebarUpdateControl() { const handleAction = useCallback(async () => { const bridge = window.desktopBridge; - if (!bridge || !state) return; if (isInteractionDisabled) return; + if (runsEverything) { + setIsConfirmingEverything(true); + return; + } + + if (!bridge || !state) return; setIsActionPending(true); if (action === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setIsActionPending(false)); + void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() => + setIsActionPending(false), + ); return; } @@ -232,30 +312,7 @@ function SidebarUpdateControl() { setIsActionPending(false); return; } - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setIsActionPending(false)); + void installLocalUpdate(bridge).finally(() => setIsActionPending(false)); return; } @@ -286,7 +343,33 @@ function SidebarUpdateControl() { ); }) .finally(() => setIsActionPending(false)); - }, [action, isInteractionDisabled, prefersReducedMotion, state]); + }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]); + + const runEverything = useCallback(async () => { + const bridge = window.desktopBridge; + setIsConfirmingEverything(false); + setIsActionPending(true); + setIsUpdatingEverything(true); + try { + await everything.run({ + ...(bridge && action === "download" + ? { downloadLocal: () => downloadLocalUpdate(bridge, { notifyDownloaded: false }) } + : {}), + ...(bridge && action !== "none" ? { installLocal: () => installLocalUpdate(bridge) } : {}), + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update everything", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } finally { + setIsUpdatingEverything(false); + setIsActionPending(false); + } + }, [action, everything]); const handleCheckAnimationIteration = useCallback(() => { setIsCheckAnimationLatched( @@ -297,6 +380,9 @@ function SidebarUpdateControl() { ); }, [prefersReducedMotion, state?.status]); + // Web has no local app to check, so it only shows the button for remote work. + if (!isElectron && !runsEverything && !isUpdatingEverything) return null; + const updateButton = ( + + + ); +} diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b38f86655091e4c3cb2a904a6e27bee1633b1492 --- /dev/null +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -0,0 +1,206 @@ +import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + collectRestartingMachines, + collectServerUpdateTargets, + describeUpdateEverything, + runUpdateEverything, + type UpdateEverythingMachine, +} from "./updateEverything.logic"; + +function machine( + id: string, + serverVersion: string, + selfUpdate: ServerSelfUpdateCapability | undefined, + overrides: Partial = {}, +): UpdateEverythingMachine { + return { + environmentId: EnvironmentId.make(id), + label: id, + serverConfig: { + environment: { + environmentId: EnvironmentId.make(id), + label: id, + platform: { os: "linux", arch: "x64" }, + serverVersion, + capabilities: { + repositoryIdentity: true, + ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), + ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), + }, + }, + settings: { continueThreadsAfterServerUpdate: false }, + }, + connected: true, + hostedByThisApp: false, + canMaintain: true, + updateState: { status: "idle" }, + ...overrides, + }; +} + +describe("collectServerUpdateTargets", () => { + it("updates only remote servers that can move to the target on their channel", () => { + const targets = collectServerUpdateTargets( + [ + machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }), + machine("service", "0.0.40", "boot-service"), + machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), + machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), + machine("offline", "0.0.40", "boot-service", { connected: false }), + machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), + machine("current", "0.0.50", "boot-service"), + machine("manual", "0.0.40", undefined), + ], + "0.0.50", + ); + + expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ + ["service", "0.0.50"], + ["other-desktop", "0.0.50"], + ]); + }); +}); + +describe("collectRestartingMachines", () => { + it("lists updated servers, and this app's servers only when it installs", () => { + const withContinuation = ( + id: string, + overrides: Partial = {}, + enabled = false, + ) => { + const base = machine(id, "0.0.40", "boot-service", overrides); + return { + ...base, + serverConfig: { + environment: { + ...base.serverConfig!.environment, + capabilities: { + ...base.serverConfig!.environment.capabilities, + threadRestartContinuation: true, + }, + }, + settings: { continueThreadsAfterServerUpdate: enabled }, + }, + }; + }; + const machines = [ + withContinuation("this-app", { hostedByThisApp: true }, true), + withContinuation("alvin", {}, true), + withContinuation("opted-out", {}, false), + machine("old", "0.0.40", "boot-service"), + withContinuation("providers-only"), + ]; + const servers = ["alvin", "opted-out", "old"].map((id) => ({ + environmentId: EnvironmentId.make(id), + })); + + expect(collectRestartingMachines(machines, servers, true)).toEqual([ + { environmentId: "this-app", continuesThreads: true }, + { environmentId: "alvin", continuesThreads: true }, + { environmentId: "opted-out", continuesThreads: false }, + { environmentId: "old", continuesThreads: false }, + ]); + expect( + collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), + ).toEqual(["alvin", "opted-out", "old"]); + }); +}); + +describe("runUpdateEverything", () => { + it("restarts servers after providers finish and installs this app last", async () => { + const calls: string[] = []; + let finishProviders = () => {}; + const run = runUpdateEverything({ + downloadLocal: async () => { + calls.push("download"); + return true; + }, + updateProviders: () => + new Promise((resolve) => { + calls.push("providers"); + finishProviders = resolve; + }), + updateServers: async () => { + calls.push("servers"); + }, + installLocal: async () => { + calls.push("install"); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual(["download", "providers"]); + finishProviders(); + await run; + expect(calls).toEqual(["download", "providers", "servers", "install"]); + }); + + it("still updates remote machines when the local download fails", async () => { + const calls: string[] = []; + await runUpdateEverything({ + downloadLocal: async () => false, + updateProviders: async () => { + calls.push("providers"); + }, + updateServers: async () => { + calls.push("servers"); + }, + installLocal: async () => { + calls.push("install"); + }, + }); + + expect(calls).toEqual(["providers", "servers"]); + }); +}); + +describe("describeUpdateEverything", () => { + it("summarizes the run and lists each machine once", () => { + const alvin = EnvironmentId.make("alvin"); + expect( + describeUpdateEverything({ + localVersion: "0.0.50", + servers: [ + { + environmentId: alvin, + serverLabel: "alvin", + selfUpdate: "boot-service", + targetVersion: "0.0.50", + }, + { + environmentId: EnvironmentId.make("studio"), + serverLabel: "studio", + selfUpdate: "desktop-managed", + targetVersion: "0.0.50", + }, + ], + providerMachines: [ + { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] }, + { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] }, + ], + }), + ).toEqual({ + summary: "Update this app, 2 servers, and 3 providers", + lines: [ + "This app: 0.0.50, restarts last", + "alvin: server to 0.0.50, Codex, Claude", + "studio: desktop app update", + "box: Cursor", + ], + }); + }); + + it("reads naturally with one kind of update", () => { + expect( + describeUpdateEverything({ + localVersion: null, + servers: [], + providerMachines: [ + { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] }, + ], + }).summary, + ).toBe("Update 1 provider"); + }); +}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d8ae1fa420691d2136e820891c19ff2873842f7 --- /dev/null +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -0,0 +1,212 @@ +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease"; + +import { + resolveServerSelfUpdateCapability, + resolveVersionMismatch, + supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, +} from "~/versionSkew"; +import type { ServerUpdateTarget } from "../ServerUpdateAction"; + +export interface UpdateEverythingMachine { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly serverConfig: { + readonly environment: ServerConfig["environment"]; + readonly settings: Pick; + } | null; + readonly connected: boolean; + /** Served by the desktop app this client runs in (the primary backend or a + local WSL backend). Its server updates when the app installs. */ + readonly hostedByThisApp: boolean; + /** The session holds the scope a server update needs. */ + readonly canMaintain: boolean; + readonly updateState: ServerUpdateState; +} + +/** A server that restarts during a run. */ +export interface RestartingMachine { + readonly environmentId: EnvironmentId; + /** The server resumes interrupted threads after it restarts. */ + readonly continuesThreads: boolean; +} + +/** + * The servers a run restarts: each server it updates, and the servers this app + * hosts when this app installs. Provider updates restart nothing. + */ +export function collectRestartingMachines( + machines: ReadonlyArray, + servers: ReadonlyArray>, + installsLocal: boolean, +): RestartingMachine[] { + const updated = new Set(servers.map((server) => server.environmentId)); + return machines.flatMap((machine) => { + const config = machine.serverConfig; + if ( + config === null || + !machine.connected || + !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp)) + ) { + return []; + } + return [ + { + environmentId: machine.environmentId, + continuesThreads: + config.environment.capabilities.threadRestartContinuation === true && + config.settings.continueThreadsAfterServerUpdate === true, + }, + ]; + }); +} + +/** + * The servers that "update everything" moves to `targetVersion`: connected, + * permitted, able to update over RPC, and behind the target. Two kinds stay + * out. Servers this app hosts would relaunch the app mid-run, and the app's + * own install updates them anyway. A service on another release channel + * stays there; a desktop-managed server follows its own app's update feed, so + * the channel rule does not apply to it. + */ +export function collectServerUpdateTargets( + machines: ReadonlyArray, + targetVersion: string, +): ServerUpdateTarget[] { + return machines.flatMap((machine) => { + const config = machine.serverConfig; + if ( + config === null || + !machine.connected || + machine.hostedByThisApp || + !machine.canMaintain || + machine.updateState.status === "running" + ) { + return []; + } + const selfUpdate = resolveServerSelfUpdateCapability(config); + const desktopAppUpdate = supportsDesktopAppUpdate(config); + if (selfUpdate === null || (selfUpdate === "desktop-managed" && !desktopAppUpdate)) { + return []; + } + const serverVersion = config.environment.serverVersion; + if (resolveVersionMismatch(serverVersion, targetVersion) === null) return []; + if ( + selfUpdate !== "desktop-managed" && + cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion) + ) { + return []; + } + return [ + { + environmentId: machine.environmentId, + serverLabel: machine.label, + selfUpdate, + installation: config.environment.capabilities.serverInstallation, + desktopAppUpdate, + threadContinuation: supportsServerUpdateThreadContinuation(config), + continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false, + targetVersion, + }, + ]; + }); +} + +export interface UpdateEverythingSteps { + /** Downloads this app's update. Resolves true when it is ready to install. */ + readonly downloadLocal?: () => Promise; + readonly updateProviders: () => Promise; + readonly updateServers: () => Promise; + /** Installs this app's update. The relaunch ends the run. */ + readonly installLocal?: () => Promise; +} + +/** + * Runs one "update everything" pass. Providers + * finish before any server restarts, so no install is cut off. The local download runs alongside the + * remote work. This app installs last because its relaunch ends the run; a + * failed download skips only that install. Steps report their own results. + */ +export async function runUpdateEverything(steps: UpdateEverythingSteps): Promise { + const [localReady] = await Promise.all([ + steps.downloadLocal?.() ?? Promise.resolve(true), + steps.updateProviders().then(steps.updateServers), + ]); + if (localReady) await steps.installLocal?.(); +} + +export interface UpdateEverythingPlan { + /** The version this desktop app installs, or null when it is not updating. */ + readonly localVersion: string | null; + readonly servers: ReadonlyArray< + Pick + >; + readonly providerMachines: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + /** Display names of the outdated providers on that machine. */ + readonly providers: ReadonlyArray; + }>; +} + +function countLabel(count: number, noun: string): string | null { + return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function joinList(parts: ReadonlyArray): string { + if (parts.length <= 2) return parts.join(" and "); + return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; +} + +/** + * Text for the sidebar tooltip and the confirmation. `summary` reads like + * "Update this app, 2 servers, and 3 providers". `lines` has one entry per + * machine, this app first, such as "alvin: server to 0.0.50, Codex, Claude". + */ +export function describeUpdateEverything(plan: UpdateEverythingPlan): { + readonly summary: string; + readonly lines: ReadonlyArray; +} { + const providerCount = plan.providerMachines.reduce( + (count, machine) => count + machine.providers.length, + 0, + ); + const summary = `Update ${joinList( + [ + plan.localVersion ? "this app" : null, + countLabel(plan.servers.length, "server"), + countLabel(providerCount, "provider"), + ].filter((part) => part !== null), + )}`; + + const machines = new Map(); + const itemsFor = (environmentId: EnvironmentId, label: string) => { + const existing = machines.get(environmentId); + if (existing) return existing.items; + const items: string[] = []; + machines.set(environmentId, { label, items }); + return items; + }; + for (const server of plan.servers) { + // A desktop-managed server installs whatever its own app's update feed + // offers, so it has no target version to show. + itemsFor(server.environmentId, server.serverLabel).push( + server.selfUpdate === "desktop-managed" + ? "desktop app update" + : `server to ${server.targetVersion}`, + ); + } + for (const machine of plan.providerMachines) { + itemsFor(machine.environmentId, machine.label).push(...machine.providers); + } + + return { + summary, + lines: [ + ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []), + ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`), + ], + }; +} diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a8dbba653d0ba7377faadb9f47af088a609f7b --- /dev/null +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -0,0 +1,94 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; + +import { APP_VERSION } from "~/branding"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isElectron } from "~/env"; +import { useEnvironments } from "~/state/environments"; +import { useNpmReleasePublished } from "~/state/npmRelease"; +import { serverEnvironment } from "~/state/server"; +import { environmentSession } from "~/state/session"; +import { useProviderUpdates } from "../ProviderUpdatesAction"; +import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction"; +import { + collectRestartingMachines, + collectServerUpdateTargets, + describeUpdateEverything, + runUpdateEverything, +} from "./updateEverything.logic"; + +/** + * The remote half of the sidebar update button: outdated providers on every + * connected machine, and servers behind the version this client runs or is + * about to install (`localUpdateVersion`). Servers that install from npm appear + * only once npm confirms that version. `run` performs the whole pass, including + * this app's own download and install when given. + */ +export function useUpdateEverything(localUpdateVersion: string | null) { + const targetVersion = localUpdateVersion ?? APP_VERSION; + const { environments } = useEnvironments(); + const machinesAtom = useMemo( + () => + Atom.make((get) => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + serverConfig: environment.serverConfig, + connected: environment.connection.phase === "connected", + hostedByThisApp: + isElectron && + (environment.entry.target._tag === "PrimaryConnectionTarget" || + isDesktopLocalConnectionTarget(environment.entry.target)), + canMaintain: canUpdateServer( + get(environmentSession.sessionStateAtom(environment.environmentId)), + ), + updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)), + })), + ), + [environments], + ); + const machines = useAtomValue(machinesAtom); + const behindServers = collectServerUpdateTargets(machines, targetVersion); + // A desktop-managed server installs from its own app's update feed, so only + // servers that download t3 from npm wait for npm to publish the version. + const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed"); + const published = useNpmReleasePublished(needsNpm ? targetVersion : null); + const servers = published + ? behindServers + : behindServers.filter((server) => server.selfUpdate === "desktop-managed"); + const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null); + const providers = useProviderUpdates(); + const updateServer = useServerUpdate(); + + const { summary, lines } = describeUpdateEverything({ + localVersion: localUpdateVersion, + servers, + providerMachines: providers.machines.map((machine) => ({ + environmentId: machine.environmentId, + label: machine.label, + providers: machine.candidates.map( + (candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver, + ), + })), + }); + + return { + hasUpdates: servers.length > 0 || providers.machines.length > 0, + /** Short label for the tooltip and the confirmation title. */ + summary, + /** One line per machine, for the confirmation. */ + lines, + /** The servers this run restarts, which stops their running threads. */ + restarts, + run: (local: { downloadLocal?: () => Promise; installLocal?: () => Promise }) => + runUpdateEverything({ + ...local, + updateProviders: providers.updateAll, + updateServers: async () => { + await Promise.all(servers.map((server) => updateServer(server))); + }, + }), + }; +} diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2820089075fe187c70ccc46ee4e5fb79a748b28 --- /dev/null +++ b/apps/web/src/state/npmRelease.ts @@ -0,0 +1,41 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { AsyncResult, Atom } from "effect/reactivity"; + +/** + * Whether `t3@` is published on npm. A 404 is false; network and + * registry errors stay unknown. Both re-check every few minutes, so a release + * that lands later shows up without a reload. + */ +const npmReleasePublishedAtom = Atom.family((version: string) => + Atom.make( + Effect.gen(function* () { + const response = yield* HttpClient.get( + `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, + ); + if (response.status === 404) return false; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap((ok) => ok.json), + ); + return typeof body === "object" && body !== null && "version" in body + ? body.version === version + : false; + }).pipe(Effect.provide(FetchHttpClient.layer)), + ).pipe( + Atom.withRefresh("5 minutes"), + Atom.keepAlive, + Atom.withLabel(`npm-release-published:${version}`), + ), +); + +const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( + Atom.withLabel("npm-release-published:none"), +); + +/** True only once npm confirms the version, so callers can hide work that + would download it until then. */ +export function useNpmReleasePublished(version: string | null): boolean { + const result = useAtomValue(version ? npmReleasePublishedAtom(version) : NO_VERSION_ATOM); + return AsyncResult.getOrElse(result, () => false); +} diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index ca8891a84a39b339fa9c6b777c9f8402b0d8f227..12ccdf011c652b2fd33e8fdfd0388f5a9759ef66 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -49,7 +49,8 @@ function versionCore(version: string): string { /** * The skew a user can act on: the connected server runs an older T3 Code than - * this client, so the server is the side that needs updating. + * this client (or than `clientVersion`, the version this client is about to + * update to), so the server is the side that needs updating. * * Two nightly builds compare their full versions, including the date and run. * Other combinations compare their core `major.minor.patch` only, so a stable @@ -59,8 +60,9 @@ function versionCore(version: string): string { */ export function resolveVersionMismatch( serverVersion: string | null | undefined, + clientVersion: string = APP_VERSION, ): VersionMismatch | null { - const normalizedClientVersion = normalizeVersion(APP_VERSION); + const normalizedClientVersion = normalizeVersion(clientVersion); const normalizedServerVersion = normalizeVersion(serverVersion); if (!normalizedClientVersion || !normalizedServerVersion) { return null; diff --git a/docs/user/updating.md b/docs/user/updating.md index 122f52be1300412127da958200ae876400fa260b..4db7b399b08950fcfe6b6d4a754909b9043845b9 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the conversation and **Settings → Connections**. Update the machine named in that notice. +## Update everything at once + +The update button at the bottom of the sidebar updates every connected machine +in one step. It updates outdated providers, then servers that are behind, then +the desktop app you are using, which restarts last. Servers move to the +version your desktop app is installing, or to your web app's version. A +background service stays on its current release channel. A remote desktop app +installs the latest release from its own channel. Hover the button to see what +it will update. + +Server updates appear only after that version is published to npm. Remote +desktop apps do not wait for npm. Servers that need +a manual update are not included. Use the notice or **Settings → Connections** +for those. + +The confirmation counts the running threads that the restarts will interrupt, +and how many can resume afterwards. See **Continue threads after restarts** +below. + ## Before you update Server updates restart the connection and can interrupt active agents and