From d0038d5246d81501ca91ed29b8780e5c945b12a5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 18:36:18 -0700 Subject: [PATCH 1/8] feat(web): sidebar update button updates every connected machine Co-Authored-By: Claude Opus 5.5 --- .../src/components/ProviderUpdatesAction.tsx | 73 +++--- .../web/src/components/ServerUpdateAction.tsx | 8 +- .../components/sidebar/SidebarUpdatePill.tsx | 235 +++++++++++++----- .../sidebar/updateEverything.logic.test.ts | 110 ++++++++ .../sidebar/updateEverything.logic.ts | 101 ++++++++ .../components/sidebar/useUpdateEverything.ts | 90 +++++++ apps/web/src/state/npmRelease.ts | 41 +++ apps/web/src/versionSkew.ts | 6 +- docs/user/updating.md | 14 ++ 9 files changed, 577 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/components/sidebar/updateEverything.logic.test.ts create mode 100644 apps/web/src/components/sidebar/updateEverything.logic.ts create mode 100644 apps/web/src/components/sidebar/useUpdateEverything.ts create mode 100644 apps/web/src/state/npmRelease.ts diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index fe435b5833efb..8d01d2e7578e2 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 e40d4f5d0792d..0f103235ce648 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/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index a1c82d3d51f38..f3fb3eeefef54 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,7 +1,8 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; import { flushSync } from "react-dom"; +import { APP_VERSION } from "../../branding"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; @@ -13,6 +14,7 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, + getDesktopUpdateDownloadedVersion, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, @@ -30,6 +32,7 @@ import { shouldShowDesktopUpdateCheckIcon, } from "./DesktopUpdateStatusIcon"; import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; +import { useUpdateEverything } from "./useUpdateEverything"; type SidebarUpdatePopoverChangeDetails = Parameters< NonNullable["onOpenChange"]> @@ -63,21 +66,23 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( function resolveSidebarUpdatePresentation({ action, + hasRemoteUpdates, isDownloading, showCheckIcon, }: { readonly action: ReturnType; + readonly hasRemoteUpdates: boolean; readonly isDownloading: boolean; readonly showCheckIcon: boolean; }) { - const showUpdateDetails = action !== "none" || isDownloading; + const showUpdateDetails = action !== "none" || hasRemoteUpdates || isDownloading; const iconStatus = showCheckIcon ? "checking" : action === "install" ? "downloaded" : isDownloading ? "downloading" - : action === "download" + : action === "download" || hasRemoteUpdates ? "available" : "idle"; @@ -108,13 +113,87 @@ function SidebarUpdateArchitectureWarningContent() { ); } +function pluralize(count: number, noun: string): string | null { + return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** 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. */ +async function installLocalUpdate(bridge: DesktopBridge): Promise { + try { + const result = await bridge.installUpdate(); + 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 [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); @@ -133,6 +212,11 @@ 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 ?? APP_VERSION); + const runsEverything = everything.hasUpdates && !isDownloading; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ isAnimationLatched: isCheckAnimationLatched, isChecking: state?.status === "checking", @@ -140,21 +224,36 @@ function SidebarUpdateControl() { }); const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, + hasRemoteUpdates: runsEverything, isDownloading, showCheckIcon, }); - const tooltip = showUpdateDetails - ? state - ? getDesktopUpdateButtonTooltip(state) - : "Update available" - : showCheckIcon - ? "Checking for updates…" - : "Check for updates"; + const everythingTooltip = `Update ${[ + localUpdateVersion ? "this app" : null, + pluralize(everything.serverCount, "server"), + pluralize(everything.providerCount, "provider"), + ] + .filter((part) => part !== null) + .join(", ")}`; + const tooltip = + isUpdatingEverything && !isDownloading + ? "Updating…" + : runsEverything + ? everythingTooltip + : 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,53 @@ function SidebarUpdateControl() { const handleAction = useCallback(async () => { const bridge = window.desktopBridge; - if (!bridge || !state) return; if (isInteractionDisabled) return; + if (runsEverything) { + setIsActionPending(true); + try { + const confirmed = await ensureLocalApi().dialogs.confirm( + [ + "Update everything?", + "", + ...(localUpdateVersion ? [`This app: install ${localUpdateVersion} and restart.`] : []), + ...everything.lines, + "", + "Running tasks on these machines may be interrupted.", + ].join("\n"), + ); + if (!confirmed) return; + setIsUpdatingEverything(true); + 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); + } + 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 +345,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 +376,15 @@ function SidebarUpdateControl() { ); }) .finally(() => setIsActionPending(false)); - }, [action, isInteractionDisabled, prefersReducedMotion, state]); + }, [ + action, + everything, + isInteractionDisabled, + localUpdateVersion, + prefersReducedMotion, + runsEverything, + state, + ]); const handleCheckAnimationIteration = useCallback(() => { setIsCheckAnimationLatched( @@ -297,6 +395,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 index 09f88fb110112..ce22cb66dfddd 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contrac import { describe, expect, it } from "vite-plus/test"; import { + collectRestartingMachines, collectServerUpdateTargets, describeUpdateEverything, runUpdateEverything, @@ -34,6 +35,7 @@ function machine( connected: true, hostedByThisApp: false, canMaintain: true, + canWriteSettings: true, updateState: { status: "idle" }, ...overrides, }; @@ -62,11 +64,59 @@ describe("collectServerUpdateTargets", () => { }); }); +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"), + withContinuation("read-only", { canWriteSettings: false }), + machine("old", "0.0.40", "boot-service"), + withContinuation("providers-only"), + ]; + const servers = ["alvin", "read-only", "old"].map((id) => ({ + environmentId: EnvironmentId.make(id), + })); + + expect(collectRestartingMachines(machines, servers, true)).toEqual([ + { environmentId: "this-app", continueSupported: true, continueEnabled: true }, + { environmentId: "alvin", continueSupported: true, continueEnabled: false }, + { environmentId: "read-only", continueSupported: false, continueEnabled: false }, + { environmentId: "old", continueSupported: false, continueEnabled: false }, + ]); + expect( + collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), + ).toEqual(["alvin", "read-only", "old"]); + }); +}); + describe("runUpdateEverything", () => { it("restarts servers after providers finish and installs this app last", async () => { const calls: string[] = []; let finishProviders = () => {}; const run = runUpdateEverything({ + prepare: async () => { + calls.push("prepare"); + }, downloadLocal: async () => { calls.push("download"); return true; @@ -84,11 +134,11 @@ describe("runUpdateEverything", () => { }, }); - await Promise.resolve(); - expect(calls).toEqual(["download", "providers"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual(["prepare", "download", "providers"]); finishProviders(); await run; - expect(calls).toEqual(["download", "providers", "servers", "install"]); + expect(calls).toEqual(["prepare", "download", "providers", "servers", "install"]); }); it("still updates remote machines when the local download fails", async () => { diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index ee6559d73d589..2526577e445fb 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -23,9 +23,50 @@ export interface UpdateEverythingMachine { readonly hostedByThisApp: boolean; /** The session holds the scope a server update needs. */ readonly canMaintain: boolean; + /** The session may change this server's settings. */ + readonly canWriteSettings: boolean; readonly updateState: ServerUpdateState; } +/** A server that restarts during a run, and its thread continuation setting. */ +export interface RestartingMachine { + readonly environmentId: EnvironmentId; + /** The server resumes threads after a restart, and this client may change that setting. */ + readonly continueSupported: boolean; + readonly continueEnabled: 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, + continueSupported: + machine.canWriteSettings && + config.environment.capabilities.threadRestartContinuation === true, + continueEnabled: config.settings.continueThreadsAfterServerUpdate ?? false, + }, + ]; + }); +} + /** * The servers that "update everything" moves to `targetVersion`: connected, * permitted, able to update over RPC, and behind the target. Two kinds stay @@ -78,6 +119,8 @@ export function collectServerUpdateTargets( } export interface UpdateEverythingSteps { + /** Runs before any other step, such as saving settings the restarts read. */ + readonly prepare?: () => Promise; /** Downloads this app's update. Resolves true when it is ready to install. */ readonly downloadLocal?: () => Promise; readonly updateProviders: () => Promise; @@ -87,12 +130,13 @@ export interface UpdateEverythingSteps { } /** - * Runs one "update everything" pass. Providers finish before any server - * restarts, so no install is cut off. The local download runs alongside the + * Runs one "update everything" pass. `prepare` finishes first. 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 { + await steps.prepare?.(); const [localReady] = await Promise.all([ steps.downloadLocal?.() ?? Promise.resolve(true), steps.updateProviders().then(steps.updateServers), diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index fd110619726d5..9abd8e9ab9cda 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { + AuthSettingsWriteScope, + PROVIDER_DISPLAY_NAMES, + sessionGrantsScope, +} from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { useMemo } from "react"; @@ -10,9 +14,11 @@ import { useEnvironments } from "~/state/environments"; import { useNpmReleasePublished } from "~/state/npmRelease"; import { serverEnvironment } from "~/state/server"; import { environmentSession } from "~/state/session"; +import { useAtomCommand } from "~/state/use-atom-command"; import { useProviderUpdates } from "../ProviderUpdatesAction"; import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction"; import { + collectRestartingMachines, collectServerUpdateTargets, describeUpdateEverything, runUpdateEverything, @@ -22,8 +28,8 @@ import { * 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. + * 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; @@ -31,20 +37,25 @@ export function useUpdateEverything(localUpdateVersion: string | null) { 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.map((environment) => { + const session = get(environmentSession.sessionStateAtom(environment.environmentId)); + return { + 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(session), + canWriteSettings: + session._tag === "Success" && + session.value.authenticated && + sessionGrantsScope(session.value, AuthSettingsWriteScope), + updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)), + }; + }), ), [environments], ); @@ -57,8 +68,10 @@ export function useUpdateEverything(localUpdateVersion: string | 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 updateSettings = useAtomCommand(serverEnvironment.updateSettings); const { summary, lines } = describeUpdateEverything({ localVersion: localUpdateVersion, @@ -78,13 +91,47 @@ export function useUpdateEverything(localUpdateVersion: string | null) { summary, /** One line per machine, for the confirmation. */ lines, - run: (local: { downloadLocal?: () => Promise; installLocal?: () => Promise }) => - runUpdateEverything({ - ...local, + /** The servers this run restarts, which stops their running threads. */ + restarts, + /** `continueThreads` sets "Continue threads after restarts" on every + restarting server before anything restarts. */ + run: (local: { + downloadLocal?: () => Promise; + installLocal?: () => Promise; + continueThreads?: boolean; + }) => { + const { continueThreads, ...localSteps } = local; + return runUpdateEverything({ + ...localSteps, + prepare: async () => { + if (continueThreads === undefined) return; + await Promise.all( + restarts + .filter( + (machine) => + machine.continueSupported && machine.continueEnabled !== continueThreads, + ) + .map((machine) => + updateSettings({ + environmentId: machine.environmentId, + input: { patch: { continueThreadsAfterServerUpdate: continueThreads } }, + }), + ), + ); + }, updateProviders: providers.updateAll, updateServers: async () => { - await Promise.all(servers.map((server) => updateServer(server))); + await Promise.all( + servers.map((server) => + updateServer( + continueThreads === undefined + ? server + : { ...server, continueThreadsAfterServerUpdate: continueThreads }, + ), + ), + ); }, - }), + }); + }, }; } diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 59401e41e7c08..d45ccd44f0681 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -50,7 +50,8 @@ export function useEnvironmentThread( return state; } -const isRunning = (status: string) => +/** A thread or run status that has an agent working right now. */ +export const isRunning = (status: string) => status === "preparing" || status === "starting" || status === "running"; type KeptThreads = ReadonlyMap>; diff --git a/docs/user/updating.md b/docs/user/updating.md index 50219fb884b97..9a547f0632e91 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -20,6 +20,10 @@ 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. +Check **Continue threads after restarts** there to turn that setting on for +the machines that restart. + ## Before you update Server updates restart the connection and can interrupt active agents and From a3896a9bc039503bd5fdb7a3f1981f72c3cd2755 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 11 Oct 2026 18:40:53 -0700 Subject: [PATCH 8/8] fix(web): update everything stops when the thread setting cannot be saved Co-Authored-By: Claude Opus 5.5 --- .../sidebar/updateEverything.logic.test.ts | 20 +++++++++++++++++++ .../sidebar/updateEverything.logic.ts | 3 ++- .../components/sidebar/useUpdateEverything.ts | 7 ++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index ce22cb66dfddd..899adc6f1aba0 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -141,6 +141,26 @@ describe("runUpdateEverything", () => { expect(calls).toEqual(["prepare", "download", "providers", "servers", "install"]); }); + it("updates nothing when prepare fails", async () => { + const calls: string[] = []; + const step = (name: string) => async () => { + calls.push(name); + return true; + }; + await expect( + runUpdateEverything({ + prepare: async () => { + throw new Error("setting not saved"); + }, + downloadLocal: step("download"), + updateProviders: async () => void (await step("providers")()), + updateServers: async () => void (await step("servers")()), + installLocal: async () => void (await step("install")()), + }), + ).rejects.toThrow("setting not saved"); + expect(calls).toEqual([]); + }); + it("still updates remote machines when the local download fails", async () => { const calls: string[] = []; await runUpdateEverything({ diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 2526577e445fb..1d1a9ff55ba52 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -130,7 +130,8 @@ export interface UpdateEverythingSteps { } /** - * Runs one "update everything" pass. `prepare` finishes first. Providers + * Runs one "update everything" pass. `prepare` finishes first, and when it + * throws nothing else runs. 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. diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index 9abd8e9ab9cda..9b372b8b3b370 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -105,7 +105,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) { ...localSteps, prepare: async () => { if (continueThreads === undefined) return; - await Promise.all( + const results = await Promise.all( restarts .filter( (machine) => @@ -118,6 +118,11 @@ export function useUpdateEverything(localUpdateVersion: string | null) { }), ), ); + // A server that missed the setting would restart against the + // user's choice, so nothing restarts. The failed write toasts itself. + if (results.some((result) => result._tag === "Failure")) { + throw new Error("Nothing was updated because a thread setting could not be saved."); + } }, updateProviders: providers.updateAll, updateServers: async () => {