| author | |
| committer | |
| log | d83929ad0bb0d4aec800f12c332de57e873cc7e5 |
| tree | b44bfd4db0d6b4a4826a012567806bce9ccdc691 |
| parent | f498b3514b0b82e39e66c549468b2b889d5e520d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 917 insertions(+), 111 deletions(-)
apps/web/src/components/ProviderUpdatesAction.tsx+43-30| ... | ... | @@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 15 | 15 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | * Updates every outdated provider on every connected machine at once, then | |
| 19 | * reports the results in one toast. Each server queues updates that share an | |
| 20 | * installer, so sending them all together is safe. The server still checks | |
| 21 | * permissions: a session that cannot operate a machine gets a failure line for | |
| 22 | * it. Renders nothing when no machine has a one-click update. | |
| 18 | * Every outdated provider with a one-click update on every connected machine, | |
| 19 | * plus a function that updates them all and reports the results in one toast. | |
| 20 | * Each server queues updates that share an installer, so sending them all | |
| 21 | * together is safe. The server still checks permissions: a session that | |
| 22 | * cannot operate a machine gets a failure line for it. The returned promise | |
| 23 | * settles when every update has finished. | |
| 23 | 24 | */ |
| 24 | export function ProviderUpdatesAction() { | |
| 25 | export function useProviderUpdates() { | |
| 25 | 26 | const { environments } = useEnvironments(); |
| 26 | 27 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { |
| 27 | 28 | reportFailure: false, |
| 28 | 29 | }); |
| 29 | const pending = useRef(false); | |
| 30 | const [isPending, setIsPending] = useState(false); | |
| 31 | 30 | const machines = useMemo( |
| 32 | 31 | () => |
| 33 | 32 | environments.flatMap((environment) => { |
| ... | ... | @@ -44,6 +43,41 @@ export function ProviderUpdatesAction() { |
| 44 | 43 | }), |
| 45 | 44 | [environments], |
| 46 | 45 | ); |
| 46 | ||
| 47 | const updateAll = async () => { | |
| 48 | const runs = await Promise.all( | |
| 49 | machines.flatMap(({ environmentId, label, candidates }) => | |
| 50 | candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({ | |
| 51 | machineLabel: label, | |
| 52 | driver: candidate.driver, | |
| 53 | instanceId: candidate.instanceId, | |
| 54 | result: await updateProvider({ | |
| 55 | environmentId, | |
| 56 | input: { provider: candidate.driver, instanceId: candidate.instanceId }, | |
| 57 | }), | |
| 58 | })), | |
| 59 | ), | |
| 60 | ); | |
| 61 | const view = getProviderUpdateRunToastView(runs); | |
| 62 | if (view) { | |
| 63 | toastManager.add( | |
| 64 | stackedThreadToast({ | |
| 65 | ...view, | |
| 66 | description: <span className="whitespace-pre-line">{view.description}</span>, | |
| 67 | }), | |
| 68 | ); | |
| 69 | } | |
| 70 | }; | |
| 71 | ||
| 72 | return { machines, updateAll }; | |
| 73 | } | |
| 74 | ||
| 75 | /** Provider settings button for {@link useProviderUpdates}. Renders nothing | |
| 76 | when no machine has a one-click update. */ | |
| 77 | export function ProviderUpdatesAction() { | |
| 78 | const { machines, updateAll } = useProviderUpdates(); | |
| 79 | const pending = useRef(false); | |
| 80 | const [isPending, setIsPending] = useState(false); | |
| 47 | 81 | // Candidates leave the list as soon as their servers report them queued, so |
| 48 | 82 | // keep the button while the run is in flight. |
| 49 | 83 | if (machines.length === 0 && !isPending) { |
| ... | ... | @@ -55,28 +89,7 @@ export function ProviderUpdatesAction() { |
| 55 | 89 | pending.current = true; |
| 56 | 90 | setIsPending(true); |
| 57 | 91 | try { |
| 58 | const runs = await Promise.all( | |
| 59 | machines.flatMap(({ environmentId, label, candidates }) => | |
| 60 | candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({ | |
| 61 | machineLabel: label, | |
| 62 | driver: candidate.driver, | |
| 63 | instanceId: candidate.instanceId, | |
| 64 | result: await updateProvider({ | |
| 65 | environmentId, | |
| 66 | input: { provider: candidate.driver, instanceId: candidate.instanceId }, | |
| 67 | }), | |
| 68 | })), | |
| 69 | ), | |
| 70 | ); | |
| 71 | const view = getProviderUpdateRunToastView(runs); | |
| 72 | if (view) { | |
| 73 | toastManager.add( | |
| 74 | stackedThreadToast({ | |
| 75 | ...view, | |
| 76 | description: <span className="whitespace-pre-line">{view.description}</span>, | |
| 77 | }), | |
| 78 | ); | |
| 79 | } | |
| 92 | await updateAll(); | |
| 80 | 93 | } finally { |
| 81 | 94 | pending.current = false; |
| 82 | 95 | setIsPending(false); |
apps/web/src/components/ServerUpdateAction.tsx+6-2| ... | ... | @@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" |
| 65 | 65 | readonly appearance?: "button" | "icon"; |
| 66 | 66 | }; |
| 67 | 67 | |
| 68 | function useServerUpdate() { | |
| 68 | /** Updates one server and waits until it is back on the target version. | |
| 69 | Reports its own success or failure toast; never rejects. */ | |
| 70 | export function useServerUpdate() { | |
| 69 | 71 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); |
| 70 | 72 | return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { |
| 71 | 73 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; |
| ... | ... | @@ -164,7 +166,9 @@ export function ServerUpdatesAction({ |
| 164 | 166 | ); |
| 165 | 167 | } |
| 166 | 168 | |
| 167 | function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean { | |
| 169 | export function canUpdateServer( | |
| 170 | result: AsyncResult.AsyncResult<AuthSessionState, unknown>, | |
| 171 | ): boolean { | |
| 168 | 172 | if (result._tag !== "Success" || !result.value.authenticated) return false; |
| 169 | 173 | return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); |
| 170 | 174 | } |
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+6-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | |
| 2 | import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | |
| 3 | 3 | import type { AnimationEventHandler } from "react"; |
| 4 | 4 | |
| 5 | 5 | const DOWNLOAD_PROGRESS_RADIUS = 14; |
| ... | ... | @@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState = |
| 10 | 10 | | "checking" |
| 11 | 11 | | "available" |
| 12 | 12 | | "downloading" |
| 13 | | "downloaded"; | |
| 13 | | "downloaded" | |
| 14 | // Other machines or providers have updates. Matches the server update icon | |
| 15 | // in Connections settings; the download arrow stays for this app's own file. | |
| 16 | | "everything"; | |
| 14 | 17 | |
| 15 | 18 | function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { |
| 16 | 19 | if (percent === null || !Number.isFinite(percent)) return 0; |
| ... | ... | @@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({ |
| 115 | 118 | return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />; |
| 116 | 119 | } |
| 117 | 120 | if (status === "downloaded") return <DesktopUpdateDownloadedIcon />; |
| 121 | if (status === "everything") return <CircleArrowUpIcon className="size-4" />; | |
| 118 | 122 | |
| 119 | 123 | return ( |
| 120 | 124 | <RefreshIcon |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+169-75| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 1 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | 2 | import { TriangleAlertIcon } from "lucide-react"; |
| 3 | 3 | import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; |
| 4 | 4 | import { flushSync } from "react-dom"; |
| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | getArm64IntelBuildWarningDescription, |
| 14 | 14 | getDesktopUpdateActionError, |
| 15 | 15 | getDesktopUpdateButtonTooltip, |
| 16 | getDesktopUpdateDownloadedVersion, | |
| 16 | 17 | getDesktopUpdateInstallConfirmationMessage, |
| 17 | 18 | isDesktopUpdateButtonDisabled, |
| 18 | 19 | resolveDesktopUpdateButtonAction, |
| ... | ... | @@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar"; |
| 26 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | 28 | import { |
| 28 | 29 | DesktopUpdateStatusIcon, |
| 30 | type DesktopUpdateStatusIconState, | |
| 29 | 31 | shouldContinueDesktopUpdateCheckAnimation, |
| 30 | 32 | shouldShowDesktopUpdateCheckIcon, |
| 31 | 33 | } from "./DesktopUpdateStatusIcon"; |
| 32 | 34 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 35 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; | |
| 36 | import { useUpdateEverything } from "./useUpdateEverything"; | |
| 33 | 37 | |
| 34 | 38 | type SidebarUpdatePopoverChangeDetails = Parameters< |
| 35 | 39 | NonNullable<ComponentProps<typeof Popover>["onOpenChange"]> |
| ... | ... | @@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( |
| 63 | 67 | |
| 64 | 68 | function resolveSidebarUpdatePresentation({ |
| 65 | 69 | action, |
| 70 | showsEverything, | |
| 66 | 71 | isDownloading, |
| 67 | 72 | showCheckIcon, |
| 68 | 73 | }: { |
| 69 | 74 | readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>; |
| 75 | /** Remote work is waiting or running, and this app is not downloading. */ | |
| 76 | readonly showsEverything: boolean; | |
| 70 | 77 | readonly isDownloading: boolean; |
| 71 | 78 | readonly showCheckIcon: boolean; |
| 72 | 79 | }) { |
| 73 | const showUpdateDetails = action !== "none" || isDownloading; | |
| 74 | const iconStatus = showCheckIcon | |
| 80 | const showUpdateDetails = action !== "none" || showsEverything || isDownloading; | |
| 81 | const iconStatus: DesktopUpdateStatusIconState = showCheckIcon | |
| 75 | 82 | ? "checking" |
| 76 | : action === "install" | |
| 77 | ? "downloaded" | |
| 78 | : isDownloading | |
| 79 | ? "downloading" | |
| 80 | : action === "download" | |
| 81 | ? "available" | |
| 82 | : "idle"; | |
| 83 | : isDownloading | |
| 84 | ? "downloading" | |
| 85 | : showsEverything | |
| 86 | ? "everything" | |
| 87 | : action === "install" | |
| 88 | ? "downloaded" | |
| 89 | : action === "download" | |
| 90 | ? "available" | |
| 91 | : "idle"; | |
| 83 | 92 | |
| 84 | 93 | return { |
| 85 | 94 | iconStatus, |
| ... | ... | @@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() { |
| 108 | 117 | ); |
| 109 | 118 | } |
| 110 | 119 | |
| 120 | /** Downloads this app's update. Resolves true when it is ready to install; | |
| 121 | failures toast and resolve false. */ | |
| 122 | async function downloadLocalUpdate( | |
| 123 | bridge: DesktopBridge, | |
| 124 | { notifyDownloaded }: { readonly notifyDownloaded: boolean }, | |
| 125 | ): Promise<boolean> { | |
| 126 | try { | |
| 127 | const result = await bridge.downloadUpdate(); | |
| 128 | if (result.completed && notifyDownloaded) { | |
| 129 | showDesktopUpdateDownloadedToast(bridge, result.state); | |
| 130 | } | |
| 131 | const actionError = shouldToastDesktopUpdateActionResult(result) | |
| 132 | ? getDesktopUpdateActionError(result) | |
| 133 | : null; | |
| 134 | if (actionError) { | |
| 135 | toastManager.add( | |
| 136 | stackedThreadToast({ | |
| 137 | type: "error", | |
| 138 | title: "Could not download update", | |
| 139 | description: actionError, | |
| 140 | }), | |
| 141 | ); | |
| 142 | } | |
| 143 | return result.completed; | |
| 144 | } catch (error) { | |
| 145 | toastManager.add( | |
| 146 | stackedThreadToast({ | |
| 147 | type: "error", | |
| 148 | title: "Could not start update download", | |
| 149 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 150 | }), | |
| 151 | ); | |
| 152 | return false; | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** Installs this app's downloaded update, which relaunches the app. | |
| 157 | Failures toast. A refused install (the download is not marked ready yet) | |
| 158 | leaves the restart to the user. */ | |
| 159 | async function installLocalUpdate(bridge: DesktopBridge): Promise<void> { | |
| 160 | try { | |
| 161 | const result = await bridge.installUpdate(); | |
| 162 | if (!result.accepted) { | |
| 163 | showDesktopUpdateDownloadedToast(bridge, result.state); | |
| 164 | return; | |
| 165 | } | |
| 166 | const actionError = shouldToastDesktopUpdateActionResult(result) | |
| 167 | ? getDesktopUpdateActionError(result) | |
| 168 | : null; | |
| 169 | if (!actionError) return; | |
| 170 | toastManager.add( | |
| 171 | stackedThreadToast({ | |
| 172 | type: "error", | |
| 173 | title: "Could not install update", | |
| 174 | description: actionError, | |
| 175 | }), | |
| 176 | ); | |
| 177 | } catch (error) { | |
| 178 | toastManager.add( | |
| 179 | stackedThreadToast({ | |
| 180 | type: "error", | |
| 181 | title: "Could not install update", | |
| 182 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 183 | }), | |
| 184 | ); | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * The sidebar update button. With only a local app update it downloads, then | |
| 190 | * installs. When other machines' servers or any providers are behind, one | |
| 191 | * click updates everything: providers, then servers, then this app last. On | |
| 192 | * web there is no local app, so the button shows only for remote updates. | |
| 193 | */ | |
| 111 | 194 | export function SidebarUpdatePill() { |
| 112 | return isElectron ? <SidebarUpdateControl /> : null; | |
| 195 | return <SidebarUpdateControl />; | |
| 113 | 196 | } |
| 114 | 197 | |
| 115 | 198 | function SidebarUpdateControl() { |
| 116 | 199 | const state = useDesktopUpdateState(); |
| 117 | 200 | const [isActionPending, setIsActionPending] = useState(false); |
| 201 | const [isUpdatingEverything, setIsUpdatingEverything] = useState(false); | |
| 202 | const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); | |
| 118 | 203 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); |
| 119 | 204 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); |
| 120 | 205 | const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); |
| ... | ... | @@ -133,6 +218,13 @@ function SidebarUpdateControl() { |
| 133 | 218 | |
| 134 | 219 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; |
| 135 | 220 | const isDownloading = state?.status === "downloading"; |
| 221 | // Remote servers follow this app to the version it is about to install. | |
| 222 | const localUpdateVersion = | |
| 223 | state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; | |
| 224 | const everything = useUpdateEverything(localUpdateVersion); | |
| 225 | const runsEverything = everything.hasUpdates && !isDownloading; | |
| 226 | // Providers leave the list once queued, so the run keeps its own state. | |
| 227 | const isRunningEverything = isUpdatingEverything && !isDownloading; | |
| 136 | 228 | const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ |
| 137 | 229 | isAnimationLatched: isCheckAnimationLatched, |
| 138 | 230 | isChecking: state?.status === "checking", |
| ... | ... | @@ -140,21 +232,28 @@ function SidebarUpdateControl() { |
| 140 | 232 | }); |
| 141 | 233 | const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ |
| 142 | 234 | action, |
| 235 | showsEverything: runsEverything || isRunningEverything, | |
| 143 | 236 | isDownloading, |
| 144 | 237 | showCheckIcon, |
| 145 | 238 | }); |
| 146 | const tooltip = showUpdateDetails | |
| 147 | ? state | |
| 148 | ? getDesktopUpdateButtonTooltip(state) | |
| 149 | : "Update available" | |
| 150 | : showCheckIcon | |
| 151 | ? "Checking for updates…" | |
| 152 | : "Check for updates"; | |
| 239 | const tooltip = isRunningEverything | |
| 240 | ? "Updating…" | |
| 241 | : runsEverything | |
| 242 | ? everything.summary | |
| 243 | : showUpdateDetails | |
| 244 | ? state | |
| 245 | ? getDesktopUpdateButtonTooltip(state) | |
| 246 | : "Update available" | |
| 247 | : showCheckIcon | |
| 248 | ? "Checking for updates…" | |
| 249 | : "Check for updates"; | |
| 153 | 250 | const disabled = showCheckIcon |
| 154 | 251 | ? true |
| 155 | : showUpdateDetails | |
| 156 | ? isDesktopUpdateButtonDisabled(state) | |
| 157 | : !canCheckForUpdate(state); | |
| 252 | : runsEverything | |
| 253 | ? false | |
| 254 | : showUpdateDetails | |
| 255 | ? isDesktopUpdateButtonDisabled(state) | |
| 256 | : !canCheckForUpdate(state); | |
| 158 | 257 | const isInteractionDisabled = disabled || isActionPending; |
| 159 | 258 | const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( |
| 160 | 259 | showUpdateDetails, |
| ... | ... | @@ -175,39 +274,20 @@ function SidebarUpdateControl() { |
| 175 | 274 | |
| 176 | 275 | const handleAction = useCallback(async () => { |
| 177 | 276 | const bridge = window.desktopBridge; |
| 178 | if (!bridge || !state) return; | |
| 179 | 277 | if (isInteractionDisabled) return; |
| 180 | 278 | |
| 279 | if (runsEverything) { | |
| 280 | setIsConfirmingEverything(true); | |
| 281 | return; | |
| 282 | } | |
| 283 | ||
| 284 | if (!bridge || !state) return; | |
| 181 | 285 | setIsActionPending(true); |
| 182 | 286 | |
| 183 | 287 | if (action === "download") { |
| 184 | void bridge | |
| 185 | .downloadUpdate() | |
| 186 | .then((result) => { | |
| 187 | if (result.completed) { | |
| 188 | showDesktopUpdateDownloadedToast(bridge, result.state); | |
| 189 | } | |
| 190 | if (!shouldToastDesktopUpdateActionResult(result)) return; | |
| 191 | const actionError = getDesktopUpdateActionError(result); | |
| 192 | if (!actionError) return; | |
| 193 | toastManager.add( | |
| 194 | stackedThreadToast({ | |
| 195 | type: "error", | |
| 196 | title: "Could not download update", | |
| 197 | description: actionError, | |
| 198 | }), | |
| 199 | ); | |
| 200 | }) | |
| 201 | .catch((error) => { | |
| 202 | toastManager.add( | |
| 203 | stackedThreadToast({ | |
| 204 | type: "error", | |
| 205 | title: "Could not start update download", | |
| 206 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 207 | }), | |
| 208 | ); | |
| 209 | }) | |
| 210 | .finally(() => setIsActionPending(false)); | |
| 288 | void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() => | |
| 289 | setIsActionPending(false), | |
| 290 | ); | |
| 211 | 291 | return; |
| 212 | 292 | } |
| 213 | 293 | |
| ... | ... | @@ -232,30 +312,7 @@ function SidebarUpdateControl() { |
| 232 | 312 | setIsActionPending(false); |
| 233 | 313 | return; |
| 234 | 314 | } |
| 235 | void bridge | |
| 236 | .installUpdate() | |
| 237 | .then((result) => { | |
| 238 | if (!shouldToastDesktopUpdateActionResult(result)) return; | |
| 239 | const actionError = getDesktopUpdateActionError(result); | |
| 240 | if (!actionError) return; | |
| 241 | toastManager.add( | |
| 242 | stackedThreadToast({ | |
| 243 | type: "error", | |
| 244 | title: "Could not install update", | |
| 245 | description: actionError, | |
| 246 | }), | |
| 247 | ); | |
| 248 | }) | |
| 249 | .catch((error) => { | |
| 250 | toastManager.add( | |
| 251 | stackedThreadToast({ | |
| 252 | type: "error", | |
| 253 | title: "Could not install update", | |
| 254 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 255 | }), | |
| 256 | ); | |
| 257 | }) | |
| 258 | .finally(() => setIsActionPending(false)); | |
| 315 | void installLocalUpdate(bridge).finally(() => setIsActionPending(false)); | |
| 259 | 316 | return; |
| 260 | 317 | } |
| 261 | 318 | |
| ... | ... | @@ -286,7 +343,33 @@ function SidebarUpdateControl() { |
| 286 | 343 | ); |
| 287 | 344 | }) |
| 288 | 345 | .finally(() => setIsActionPending(false)); |
| 289 | }, [action, isInteractionDisabled, prefersReducedMotion, state]); | |
| 346 | }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]); | |
| 347 | ||
| 348 | const runEverything = useCallback(async () => { | |
| 349 | const bridge = window.desktopBridge; | |
| 350 | setIsConfirmingEverything(false); | |
| 351 | setIsActionPending(true); | |
| 352 | setIsUpdatingEverything(true); | |
| 353 | try { | |
| 354 | await everything.run({ | |
| 355 | ...(bridge && action === "download" | |
| 356 | ? { downloadLocal: () => downloadLocalUpdate(bridge, { notifyDownloaded: false }) } | |
| 357 | : {}), | |
| 358 | ...(bridge && action !== "none" ? { installLocal: () => installLocalUpdate(bridge) } : {}), | |
| 359 | }); | |
| 360 | } catch (error) { | |
| 361 | toastManager.add( | |
| 362 | stackedThreadToast({ | |
| 363 | type: "error", | |
| 364 | title: "Could not update everything", | |
| 365 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 366 | }), | |
| 367 | ); | |
| 368 | } finally { | |
| 369 | setIsUpdatingEverything(false); | |
| 370 | setIsActionPending(false); | |
| 371 | } | |
| 372 | }, [action, everything]); | |
| 290 | 373 | |
| 291 | 374 | const handleCheckAnimationIteration = useCallback(() => { |
| 292 | 375 | setIsCheckAnimationLatched( |
| ... | ... | @@ -297,6 +380,9 @@ function SidebarUpdateControl() { |
| 297 | 380 | ); |
| 298 | 381 | }, [prefersReducedMotion, state?.status]); |
| 299 | 382 | |
| 383 | // Web has no local app to check, so it only shows the button for remote work. | |
| 384 | if (!isElectron && !runsEverything && !isUpdatingEverything) return null; | |
| 385 | ||
| 300 | 386 | const updateButton = ( |
| 301 | 387 | <button |
| 302 | 388 | type="button" |
| ... | ... | @@ -314,7 +400,7 @@ function SidebarUpdateControl() { |
| 314 | 400 | "text-(--sidebar-icon-color)", |
| 315 | 401 | !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| 316 | 402 | ), |
| 317 | disabled && !showUpdateIconState && "opacity-60", | |
| 403 | ((disabled && !showUpdateIconState) || isRunningEverything) && "opacity-60", | |
| 318 | 404 | )} |
| 319 | 405 | onClick={handleAction} |
| 320 | 406 | onBlur={() => { |
| ... | ... | @@ -414,6 +500,14 @@ function SidebarUpdateControl() { |
| 414 | 500 | </PopoverPopup> |
| 415 | 501 | ) : null} |
| 416 | 502 | </Popover> |
| 503 | <UpdateEverythingDialog | |
| 504 | open={isConfirmingEverything} | |
| 505 | onOpenChange={setIsConfirmingEverything} | |
| 506 | summary={everything.summary} | |
| 507 | lines={everything.lines} | |
| 508 | restarts={everything.restarts} | |
| 509 | onConfirm={() => void runEverything()} | |
| 510 | /> | |
| 417 | 511 | </SidebarMenuItem> |
| 418 | 512 | ); |
| 419 | 513 | } |
apps/web/src/components/sidebar/UpdateEverythingDialog.tsx created+117| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { Atom } from "effect/reactivity"; | |
| 4 | ||
| 5 | import { environmentThreadShells } from "~/state/threads"; | |
| 6 | import { | |
| 7 | AlertDialog, | |
| 8 | AlertDialogClose, | |
| 9 | AlertDialogDescription, | |
| 10 | AlertDialogFooter, | |
| 11 | AlertDialogHeader, | |
| 12 | AlertDialogPopup, | |
| 13 | AlertDialogTitle, | |
| 14 | } from "../ui/alert-dialog"; | |
| 15 | import { Button } from "../ui/button"; | |
| 16 | import type { RestartingMachine } from "./updateEverything.logic"; | |
| 17 | ||
| 18 | /** A thread an agent is working on now. A restart stops these; queued threads wait. */ | |
| 19 | const isRunning = (status: string) => | |
| 20 | status === "preparing" || status === "starting" || status === "running"; | |
| 21 | ||
| 22 | /** Running threads across the environments in a newline-joined key. */ | |
| 23 | const runningThreadCountAtom = Atom.family((environmentIds: string) => | |
| 24 | Atom.make((get) => | |
| 25 | environmentIds | |
| 26 | .split("\n") | |
| 27 | .filter((id) => id !== "") | |
| 28 | .reduce( | |
| 29 | (count, id) => | |
| 30 | count + | |
| 31 | get(environmentThreadShells.environmentThreadsAtom(EnvironmentId.make(id))).filter( | |
| 32 | (thread) => isRunning(thread.status), | |
| 33 | ).length, | |
| 34 | 0, | |
| 35 | ), | |
| 36 | ).pipe(Atom.withLabel(`update-everything-running-threads:${environmentIds}`)), | |
| 37 | ); | |
| 38 | ||
| 39 | interface UpdateEverythingDialogProps { | |
| 40 | readonly open: boolean; | |
| 41 | readonly onOpenChange: (open: boolean) => void; | |
| 42 | readonly summary: string; | |
| 43 | readonly lines: ReadonlyArray<string>; | |
| 44 | readonly restarts: ReadonlyArray<RestartingMachine>; | |
| 45 | readonly onConfirm: () => void; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Confirms an "update everything" run. It counts the running threads that the | |
| 50 | * restarts stop, and how many can resume afterwards because "Continue threads | |
| 51 | * after restarts" is on for their server. Recovery still skips some, such as a | |
| 52 | * thread waiting on the user, so the text says "can resume", not "will". | |
| 53 | */ | |
| 54 | export function UpdateEverythingDialog({ | |
| 55 | open, | |
| 56 | onOpenChange, | |
| 57 | ...content | |
| 58 | }: UpdateEverythingDialogProps) { | |
| 59 | return ( | |
| 60 | <AlertDialog open={open} onOpenChange={onOpenChange}> | |
| 61 | <AlertDialogPopup> | |
| 62 | {/* The popup unmounts when closed, so the thread count is read only while open. */} | |
| 63 | <UpdateEverythingDialogContent {...content} /> | |
| 64 | </AlertDialogPopup> | |
| 65 | </AlertDialog> | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | function UpdateEverythingDialogContent({ | |
| 70 | summary, | |
| 71 | lines, | |
| 72 | restarts, | |
| 73 | onConfirm, | |
| 74 | }: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) { | |
| 75 | const stopped = useAtomValue( | |
| 76 | runningThreadCountAtom( | |
| 77 | restarts | |
| 78 | .filter((machine) => !machine.continuesThreads) | |
| 79 | .map((machine) => machine.environmentId) | |
| 80 | .join("\n"), | |
| 81 | ), | |
| 82 | ); | |
| 83 | const continuing = useAtomValue( | |
| 84 | runningThreadCountAtom( | |
| 85 | restarts | |
| 86 | .filter((machine) => machine.continuesThreads) | |
| 87 | .map((machine) => machine.environmentId) | |
| 88 | .join("\n"), | |
| 89 | ), | |
| 90 | ); | |
| 91 | const running = stopped + continuing; | |
| 92 | const threads = `${running} running ${running === 1 ? "thread" : "threads"}`; | |
| 93 | ||
| 94 | return ( | |
| 95 | <> | |
| 96 | <AlertDialogHeader> | |
| 97 | <AlertDialogTitle>{summary}?</AlertDialogTitle> | |
| 98 | <AlertDialogDescription className="whitespace-pre-line"> | |
| 99 | {lines.join("\n")} | |
| 100 | </AlertDialogDescription> | |
| 101 | {running > 0 ? ( | |
| 102 | <p className="pt-2 text-sm"> | |
| 103 | {stopped === 0 | |
| 104 | ? `${threads} will be interrupted and can resume after the restart.` | |
| 105 | : continuing === 0 | |
| 106 | ? `${threads} will be interrupted.` | |
| 107 | : `${threads} will be interrupted. ${continuing} can resume after the restart.`} | |
| 108 | </p> | |
| 109 | ) : null} | |
| 110 | </AlertDialogHeader> | |
| 111 | <AlertDialogFooter> | |
| 112 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 113 | <Button onClick={onConfirm}>Update</Button> | |
| 114 | </AlertDialogFooter> | |
| 115 | </> | |
| 116 | ); | |
| 117 | } |
apps/web/src/components/sidebar/updateEverything.logic.test.ts created+206| ... | ... | @@ -0,0 +1,206 @@ |
| 1 | import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | collectRestartingMachines, | |
| 6 | collectServerUpdateTargets, | |
| 7 | describeUpdateEverything, | |
| 8 | runUpdateEverything, | |
| 9 | type UpdateEverythingMachine, | |
| 10 | } from "./updateEverything.logic"; | |
| 11 | ||
| 12 | function machine( | |
| 13 | id: string, | |
| 14 | serverVersion: string, | |
| 15 | selfUpdate: ServerSelfUpdateCapability | undefined, | |
| 16 | overrides: Partial<UpdateEverythingMachine> = {}, | |
| 17 | ): UpdateEverythingMachine { | |
| 18 | return { | |
| 19 | environmentId: EnvironmentId.make(id), | |
| 20 | label: id, | |
| 21 | serverConfig: { | |
| 22 | environment: { | |
| 23 | environmentId: EnvironmentId.make(id), | |
| 24 | label: id, | |
| 25 | platform: { os: "linux", arch: "x64" }, | |
| 26 | serverVersion, | |
| 27 | capabilities: { | |
| 28 | repositoryIdentity: true, | |
| 29 | ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), | |
| 30 | ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), | |
| 31 | }, | |
| 32 | }, | |
| 33 | settings: { continueThreadsAfterServerUpdate: false }, | |
| 34 | }, | |
| 35 | connected: true, | |
| 36 | hostedByThisApp: false, | |
| 37 | canMaintain: true, | |
| 38 | updateState: { status: "idle" }, | |
| 39 | ...overrides, | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | describe("collectServerUpdateTargets", () => { | |
| 44 | it("updates only remote servers that can move to the target on their channel", () => { | |
| 45 | const targets = collectServerUpdateTargets( | |
| 46 | [ | |
| 47 | machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }), | |
| 48 | machine("service", "0.0.40", "boot-service"), | |
| 49 | machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), | |
| 50 | machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), | |
| 51 | machine("offline", "0.0.40", "boot-service", { connected: false }), | |
| 52 | machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), | |
| 53 | machine("current", "0.0.50", "boot-service"), | |
| 54 | machine("manual", "0.0.40", undefined), | |
| 55 | ], | |
| 56 | "0.0.50", | |
| 57 | ); | |
| 58 | ||
| 59 | expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ | |
| 60 | ["service", "0.0.50"], | |
| 61 | ["other-desktop", "0.0.50"], | |
| 62 | ]); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("collectRestartingMachines", () => { | |
| 67 | it("lists updated servers, and this app's servers only when it installs", () => { | |
| 68 | const withContinuation = ( | |
| 69 | id: string, | |
| 70 | overrides: Partial<UpdateEverythingMachine> = {}, | |
| 71 | enabled = false, | |
| 72 | ) => { | |
| 73 | const base = machine(id, "0.0.40", "boot-service", overrides); | |
| 74 | return { | |
| 75 | ...base, | |
| 76 | serverConfig: { | |
| 77 | environment: { | |
| 78 | ...base.serverConfig!.environment, | |
| 79 | capabilities: { | |
| 80 | ...base.serverConfig!.environment.capabilities, | |
| 81 | threadRestartContinuation: true, | |
| 82 | }, | |
| 83 | }, | |
| 84 | settings: { continueThreadsAfterServerUpdate: enabled }, | |
| 85 | }, | |
| 86 | }; | |
| 87 | }; | |
| 88 | const machines = [ | |
| 89 | withContinuation("this-app", { hostedByThisApp: true }, true), | |
| 90 | withContinuation("alvin", {}, true), | |
| 91 | withContinuation("opted-out", {}, false), | |
| 92 | machine("old", "0.0.40", "boot-service"), | |
| 93 | withContinuation("providers-only"), | |
| 94 | ]; | |
| 95 | const servers = ["alvin", "opted-out", "old"].map((id) => ({ | |
| 96 | environmentId: EnvironmentId.make(id), | |
| 97 | })); | |
| 98 | ||
| 99 | expect(collectRestartingMachines(machines, servers, true)).toEqual([ | |
| 100 | { environmentId: "this-app", continuesThreads: true }, | |
| 101 | { environmentId: "alvin", continuesThreads: true }, | |
| 102 | { environmentId: "opted-out", continuesThreads: false }, | |
| 103 | { environmentId: "old", continuesThreads: false }, | |
| 104 | ]); | |
| 105 | expect( | |
| 106 | collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), | |
| 107 | ).toEqual(["alvin", "opted-out", "old"]); | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("runUpdateEverything", () => { | |
| 112 | it("restarts servers after providers finish and installs this app last", async () => { | |
| 113 | const calls: string[] = []; | |
| 114 | let finishProviders = () => {}; | |
| 115 | const run = runUpdateEverything({ | |
| 116 | downloadLocal: async () => { | |
| 117 | calls.push("download"); | |
| 118 | return true; | |
| 119 | }, | |
| 120 | updateProviders: () => | |
| 121 | new Promise<void>((resolve) => { | |
| 122 | calls.push("providers"); | |
| 123 | finishProviders = resolve; | |
| 124 | }), | |
| 125 | updateServers: async () => { | |
| 126 | calls.push("servers"); | |
| 127 | }, | |
| 128 | installLocal: async () => { | |
| 129 | calls.push("install"); | |
| 130 | }, | |
| 131 | }); | |
| 132 | ||
| 133 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 134 | expect(calls).toEqual(["download", "providers"]); | |
| 135 | finishProviders(); | |
| 136 | await run; | |
| 137 | expect(calls).toEqual(["download", "providers", "servers", "install"]); | |
| 138 | }); | |
| 139 | ||
| 140 | it("still updates remote machines when the local download fails", async () => { | |
| 141 | const calls: string[] = []; | |
| 142 | await runUpdateEverything({ | |
| 143 | downloadLocal: async () => false, | |
| 144 | updateProviders: async () => { | |
| 145 | calls.push("providers"); | |
| 146 | }, | |
| 147 | updateServers: async () => { | |
| 148 | calls.push("servers"); | |
| 149 | }, | |
| 150 | installLocal: async () => { | |
| 151 | calls.push("install"); | |
| 152 | }, | |
| 153 | }); | |
| 154 | ||
| 155 | expect(calls).toEqual(["providers", "servers"]); | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | describe("describeUpdateEverything", () => { | |
| 160 | it("summarizes the run and lists each machine once", () => { | |
| 161 | const alvin = EnvironmentId.make("alvin"); | |
| 162 | expect( | |
| 163 | describeUpdateEverything({ | |
| 164 | localVersion: "0.0.50", | |
| 165 | servers: [ | |
| 166 | { | |
| 167 | environmentId: alvin, | |
| 168 | serverLabel: "alvin", | |
| 169 | selfUpdate: "boot-service", | |
| 170 | targetVersion: "0.0.50", | |
| 171 | }, | |
| 172 | { | |
| 173 | environmentId: EnvironmentId.make("studio"), | |
| 174 | serverLabel: "studio", | |
| 175 | selfUpdate: "desktop-managed", | |
| 176 | targetVersion: "0.0.50", | |
| 177 | }, | |
| 178 | ], | |
| 179 | providerMachines: [ | |
| 180 | { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] }, | |
| 181 | { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] }, | |
| 182 | ], | |
| 183 | }), | |
| 184 | ).toEqual({ | |
| 185 | summary: "Update this app, 2 servers, and 3 providers", | |
| 186 | lines: [ | |
| 187 | "This app: 0.0.50, restarts last", | |
| 188 | "alvin: server to 0.0.50, Codex, Claude", | |
| 189 | "studio: desktop app update", | |
| 190 | "box: Cursor", | |
| 191 | ], | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | it("reads naturally with one kind of update", () => { | |
| 196 | expect( | |
| 197 | describeUpdateEverything({ | |
| 198 | localVersion: null, | |
| 199 | servers: [], | |
| 200 | providerMachines: [ | |
| 201 | { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] }, | |
| 202 | ], | |
| 203 | }).summary, | |
| 204 | ).toBe("Update 1 provider"); | |
| 205 | }); | |
| 206 | }); |
apps/web/src/components/sidebar/updateEverything.logic.ts created+212| ... | ... | @@ -0,0 +1,212 @@ |
| 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 { 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 | /** | |
| 67 | * The servers that "update everything" moves to `targetVersion`: connected, | |
| 68 | * permitted, able to update over RPC, and behind the target. Two kinds stay | |
| 69 | * out. Servers this app hosts would relaunch the app mid-run, and the app's | |
| 70 | * own install updates them anyway. A service on another release channel | |
| 71 | * stays there; a desktop-managed server follows its own app's update feed, so | |
| 72 | * the channel rule does not apply to it. | |
| 73 | */ | |
| 74 | export function collectServerUpdateTargets( | |
| 75 | machines: ReadonlyArray<UpdateEverythingMachine>, | |
| 76 | targetVersion: string, | |
| 77 | ): ServerUpdateTarget[] { | |
| 78 | return machines.flatMap((machine) => { | |
| 79 | const config = machine.serverConfig; | |
| 80 | if ( | |
| 81 | config === null || | |
| 82 | !machine.connected || | |
| 83 | machine.hostedByThisApp || | |
| 84 | !machine.canMaintain || | |
| 85 | machine.updateState.status === "running" | |
| 86 | ) { | |
| 87 | return []; | |
| 88 | } | |
| 89 | const selfUpdate = resolveServerSelfUpdateCapability(config); | |
| 90 | const desktopAppUpdate = supportsDesktopAppUpdate(config); | |
| 91 | if (selfUpdate === null || (selfUpdate === "desktop-managed" && !desktopAppUpdate)) { | |
| 92 | return []; | |
| 93 | } | |
| 94 | const serverVersion = config.environment.serverVersion; | |
| 95 | if (resolveVersionMismatch(serverVersion, targetVersion) === null) return []; | |
| 96 | if ( | |
| 97 | selfUpdate !== "desktop-managed" && | |
| 98 | cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion) | |
| 99 | ) { | |
| 100 | return []; | |
| 101 | } | |
| 102 | return [ | |
| 103 | { | |
| 104 | environmentId: machine.environmentId, | |
| 105 | serverLabel: machine.label, | |
| 106 | selfUpdate, | |
| 107 | installation: config.environment.capabilities.serverInstallation, | |
| 108 | desktopAppUpdate, | |
| 109 | threadContinuation: supportsServerUpdateThreadContinuation(config), | |
| 110 | continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false, | |
| 111 | targetVersion, | |
| 112 | }, | |
| 113 | ]; | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | export interface UpdateEverythingSteps { | |
| 118 | /** Downloads this app's update. Resolves true when it is ready to install. */ | |
| 119 | readonly downloadLocal?: () => Promise<boolean>; | |
| 120 | readonly updateProviders: () => Promise<void>; | |
| 121 | readonly updateServers: () => Promise<void>; | |
| 122 | /** Installs this app's update. The relaunch ends the run. */ | |
| 123 | readonly installLocal?: () => Promise<void>; | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Runs one "update everything" pass. Providers | |
| 128 | * finish before any server restarts, so no install is cut off. The local download runs alongside the | |
| 129 | * remote work. This app installs last because its relaunch ends the run; a | |
| 130 | * failed download skips only that install. Steps report their own results. | |
| 131 | */ | |
| 132 | export async function runUpdateEverything(steps: UpdateEverythingSteps): Promise<void> { | |
| 133 | const [localReady] = await Promise.all([ | |
| 134 | steps.downloadLocal?.() ?? Promise.resolve(true), | |
| 135 | steps.updateProviders().then(steps.updateServers), | |
| 136 | ]); | |
| 137 | if (localReady) await steps.installLocal?.(); | |
| 138 | } | |
| 139 | ||
| 140 | export interface UpdateEverythingPlan { | |
| 141 | /** The version this desktop app installs, or null when it is not updating. */ | |
| 142 | readonly localVersion: string | null; | |
| 143 | readonly servers: ReadonlyArray< | |
| 144 | Pick<ServerUpdateTarget, "environmentId" | "serverLabel" | "selfUpdate" | "targetVersion"> | |
| 145 | >; | |
| 146 | readonly providerMachines: ReadonlyArray<{ | |
| 147 | readonly environmentId: EnvironmentId; | |
| 148 | readonly label: string; | |
| 149 | /** Display names of the outdated providers on that machine. */ | |
| 150 | readonly providers: ReadonlyArray<string>; | |
| 151 | }>; | |
| 152 | } | |
| 153 | ||
| 154 | function countLabel(count: number, noun: string): string | null { | |
| 155 | return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`; | |
| 156 | } | |
| 157 | ||
| 158 | function joinList(parts: ReadonlyArray<string>): string { | |
| 159 | if (parts.length <= 2) return parts.join(" and "); | |
| 160 | return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; | |
| 161 | } | |
| 162 | ||
| 163 | /** | |
| 164 | * Text for the sidebar tooltip and the confirmation. `summary` reads like | |
| 165 | * "Update this app, 2 servers, and 3 providers". `lines` has one entry per | |
| 166 | * machine, this app first, such as "alvin: server to 0.0.50, Codex, Claude". | |
| 167 | */ | |
| 168 | export function describeUpdateEverything(plan: UpdateEverythingPlan): { | |
| 169 | readonly summary: string; | |
| 170 | readonly lines: ReadonlyArray<string>; | |
| 171 | } { | |
| 172 | const providerCount = plan.providerMachines.reduce( | |
| 173 | (count, machine) => count + machine.providers.length, | |
| 174 | 0, | |
| 175 | ); | |
| 176 | const summary = `Update ${joinList( | |
| 177 | [ | |
| 178 | plan.localVersion ? "this app" : null, | |
| 179 | countLabel(plan.servers.length, "server"), | |
| 180 | countLabel(providerCount, "provider"), | |
| 181 | ].filter((part) => part !== null), | |
| 182 | )}`; | |
| 183 | ||
| 184 | const machines = new Map<EnvironmentId, { label: string; items: string[] }>(); | |
| 185 | const itemsFor = (environmentId: EnvironmentId, label: string) => { | |
| 186 | const existing = machines.get(environmentId); | |
| 187 | if (existing) return existing.items; | |
| 188 | const items: string[] = []; | |
| 189 | machines.set(environmentId, { label, items }); | |
| 190 | return items; | |
| 191 | }; | |
| 192 | for (const server of plan.servers) { | |
| 193 | // A desktop-managed server installs whatever its own app's update feed | |
| 194 | // offers, so it has no target version to show. | |
| 195 | itemsFor(server.environmentId, server.serverLabel).push( | |
| 196 | server.selfUpdate === "desktop-managed" | |
| 197 | ? "desktop app update" | |
| 198 | : `server to ${server.targetVersion}`, | |
| 199 | ); | |
| 200 | } | |
| 201 | for (const machine of plan.providerMachines) { | |
| 202 | itemsFor(machine.environmentId, machine.label).push(...machine.providers); | |
| 203 | } | |
| 204 | ||
| 205 | return { | |
| 206 | summary, | |
| 207 | lines: [ | |
| 208 | ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []), | |
| 209 | ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`), | |
| 210 | ], | |
| 211 | }; | |
| 212 | } |
apps/web/src/components/sidebar/useUpdateEverything.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | |
| 3 | import { Atom } from "effect/reactivity"; | |
| 4 | import { useMemo } from "react"; | |
| 5 | ||
| 6 | import { APP_VERSION } from "~/branding"; | |
| 7 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | |
| 8 | import { isElectron } from "~/env"; | |
| 9 | import { useEnvironments } from "~/state/environments"; | |
| 10 | import { useNpmReleasePublished } from "~/state/npmRelease"; | |
| 11 | import { serverEnvironment } from "~/state/server"; | |
| 12 | import { environmentSession } from "~/state/session"; | |
| 13 | import { useProviderUpdates } from "../ProviderUpdatesAction"; | |
| 14 | import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction"; | |
| 15 | import { | |
| 16 | collectRestartingMachines, | |
| 17 | collectServerUpdateTargets, | |
| 18 | describeUpdateEverything, | |
| 19 | runUpdateEverything, | |
| 20 | } from "./updateEverything.logic"; | |
| 21 | ||
| 22 | /** | |
| 23 | * The remote half of the sidebar update button: outdated providers on every | |
| 24 | * connected machine, and servers behind the version this client runs or is | |
| 25 | * about to install (`localUpdateVersion`). Servers that install from npm appear | |
| 26 | * only once npm confirms that version. `run` performs the whole pass, including | |
| 27 | * this app's own download and install when given. | |
| 28 | */ | |
| 29 | export function useUpdateEverything(localUpdateVersion: string | null) { | |
| 30 | const targetVersion = localUpdateVersion ?? APP_VERSION; | |
| 31 | const { environments } = useEnvironments(); | |
| 32 | const machinesAtom = useMemo( | |
| 33 | () => | |
| 34 | Atom.make((get) => | |
| 35 | environments.map((environment) => ({ | |
| 36 | environmentId: environment.environmentId, | |
| 37 | label: environment.label, | |
| 38 | serverConfig: environment.serverConfig, | |
| 39 | connected: environment.connection.phase === "connected", | |
| 40 | hostedByThisApp: | |
| 41 | isElectron && | |
| 42 | (environment.entry.target._tag === "PrimaryConnectionTarget" || | |
| 43 | isDesktopLocalConnectionTarget(environment.entry.target)), | |
| 44 | canMaintain: canUpdateServer( | |
| 45 | get(environmentSession.sessionStateAtom(environment.environmentId)), | |
| 46 | ), | |
| 47 | updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)), | |
| 48 | })), | |
| 49 | ), | |
| 50 | [environments], | |
| 51 | ); | |
| 52 | const machines = useAtomValue(machinesAtom); | |
| 53 | const behindServers = collectServerUpdateTargets(machines, targetVersion); | |
| 54 | // A desktop-managed server installs from its own app's update feed, so only | |
| 55 | // servers that download t3 from npm wait for npm to publish the version. | |
| 56 | const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed"); | |
| 57 | const published = useNpmReleasePublished(needsNpm ? targetVersion : null); | |
| 58 | const servers = published | |
| 59 | ? behindServers | |
| 60 | : behindServers.filter((server) => server.selfUpdate === "desktop-managed"); | |
| 61 | const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null); | |
| 62 | const providers = useProviderUpdates(); | |
| 63 | const updateServer = useServerUpdate(); | |
| 64 | ||
| 65 | const { summary, lines } = describeUpdateEverything({ | |
| 66 | localVersion: localUpdateVersion, | |
| 67 | servers, | |
| 68 | providerMachines: providers.machines.map((machine) => ({ | |
| 69 | environmentId: machine.environmentId, | |
| 70 | label: machine.label, | |
| 71 | providers: machine.candidates.map( | |
| 72 | (candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver, | |
| 73 | ), | |
| 74 | })), | |
| 75 | }); | |
| 76 | ||
| 77 | return { | |
| 78 | hasUpdates: servers.length > 0 || providers.machines.length > 0, | |
| 79 | /** Short label for the tooltip and the confirmation title. */ | |
| 80 | summary, | |
| 81 | /** One line per machine, for the confirmation. */ | |
| 82 | lines, | |
| 83 | /** The servers this run restarts, which stops their running threads. */ | |
| 84 | restarts, | |
| 85 | run: (local: { downloadLocal?: () => Promise<boolean>; installLocal?: () => Promise<void> }) => | |
| 86 | runUpdateEverything({ | |
| 87 | ...local, | |
| 88 | updateProviders: providers.updateAll, | |
| 89 | updateServers: async () => { | |
| 90 | await Promise.all(servers.map((server) => updateServer(server))); | |
| 91 | }, | |
| 92 | }), | |
| 93 | }; | |
| 94 | } |
apps/web/src/state/npmRelease.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; | |
| 4 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 5 | ||
| 6 | /** | |
| 7 | * Whether `t3@<version>` is published on npm. A 404 is false; network and | |
| 8 | * registry errors stay unknown. Both re-check every few minutes, so a release | |
| 9 | * that lands later shows up without a reload. | |
| 10 | */ | |
| 11 | const npmReleasePublishedAtom = Atom.family((version: string) => | |
| 12 | Atom.make( | |
| 13 | Effect.gen(function* () { | |
| 14 | const response = yield* HttpClient.get( | |
| 15 | `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, | |
| 16 | ); | |
| 17 | if (response.status === 404) return false; | |
| 18 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | |
| 19 | Effect.flatMap((ok) => ok.json), | |
| 20 | ); | |
| 21 | return typeof body === "object" && body !== null && "version" in body | |
| 22 | ? body.version === version | |
| 23 | : false; | |
| 24 | }).pipe(Effect.provide(FetchHttpClient.layer)), | |
| 25 | ).pipe( | |
| 26 | Atom.withRefresh("5 minutes"), | |
| 27 | Atom.keepAlive, | |
| 28 | Atom.withLabel(`npm-release-published:${version}`), | |
| 29 | ), | |
| 30 | ); | |
| 31 | ||
| 32 | const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( | |
| 33 | Atom.withLabel("npm-release-published:none"), | |
| 34 | ); | |
| 35 | ||
| 36 | /** True only once npm confirms the version, so callers can hide work that | |
| 37 | would download it until then. */ | |
| 38 | export function useNpmReleasePublished(version: string | null): boolean { | |
| 39 | const result = useAtomValue(version ? npmReleasePublishedAtom(version) : NO_VERSION_ATOM); | |
| 40 | return AsyncResult.getOrElse(result, () => false); | |
| 41 | } |
apps/web/src/versionSkew.ts+4-2| ... | ... | @@ -49,7 +49,8 @@ function versionCore(version: string): string { |
| 49 | 49 | |
| 50 | 50 | /** |
| 51 | 51 | * The skew a user can act on: the connected server runs an older T3 Code than |
| 52 | * this client, so the server is the side that needs updating. | |
| 52 | * this client (or than `clientVersion`, the version this client is about to | |
| 53 | * update to), so the server is the side that needs updating. | |
| 53 | 54 | * |
| 54 | 55 | * Two nightly builds compare their full versions, including the date and run. |
| 55 | 56 | * Other combinations compare their core `major.minor.patch` only, so a stable |
| ... | ... | @@ -59,8 +60,9 @@ function versionCore(version: string): string { |
| 59 | 60 | */ |
| 60 | 61 | export function resolveVersionMismatch( |
| 61 | 62 | serverVersion: string | null | undefined, |
| 63 | clientVersion: string = APP_VERSION, | |
| 62 | 64 | ): VersionMismatch | null { |
| 63 | const normalizedClientVersion = normalizeVersion(APP_VERSION); | |
| 65 | const normalizedClientVersion = normalizeVersion(clientVersion); | |
| 64 | 66 | const normalizedServerVersion = normalizeVersion(serverVersion); |
| 65 | 67 | if (!normalizedClientVersion || !normalizedServerVersion) { |
| 66 | 68 | return null; |
docs/user/updating.md+19| ... | ... | @@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the |
| 5 | 5 | conversation and **Settings → Connections**. Update the machine named in that |
| 6 | 6 | notice. |
| 7 | 7 | |
| 8 | ## Update everything at once | |
| 9 | ||
| 10 | The update button at the bottom of the sidebar updates every connected machine | |
| 11 | in one step. It updates outdated providers, then servers that are behind, then | |
| 12 | the desktop app you are using, which restarts last. Servers move to the | |
| 13 | version your desktop app is installing, or to your web app's version. A | |
| 14 | background service stays on its current release channel. A remote desktop app | |
| 15 | installs the latest release from its own channel. Hover the button to see what | |
| 16 | it will update. | |
| 17 | ||
| 18 | Server updates appear only after that version is published to npm. Remote | |
| 19 | desktop apps do not wait for npm. Servers that need | |
| 20 | a manual update are not included. Use the notice or **Settings → Connections** | |
| 21 | for those. | |
| 22 | ||
| 23 | The confirmation counts the running threads that the restarts will interrupt, | |
| 24 | and how many can resume afterwards. See **Continue threads after restarts** | |
| 25 | below. | |
| 26 | ||
| 8 | 27 | ## Before you update |
| 9 | 28 | |
| 10 | 29 | Server updates restart the connection and can interrupt active agents and |