diff --git a/.changeset/fn-104-pending-update-restart.md b/.changeset/fn-104-pending-update-restart.md new file mode 100644 index 0000000000..ac6e9b09e8 --- /dev/null +++ b/.changeset/fn-104-pending-update-restart.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve installed update restart state when Settings reopens. +category: fix +dev: The old dashboard process exposes its pending install until replacement. diff --git a/docs/architecture.md b/docs/architecture.md index fb36160136..1160d24155 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1127,8 +1127,11 @@ The client treats mapping persistence as part of onboarding success. If mapping | GET | `/api/settings/auth-export` | Export local `AuthMaterialSnapshot`. | | GET | `/api/update-check` | Read cached/TTL-guarded npm update status for `@runfusion/fusion` (respects `updateCheckEnabled`). | | POST | `/api/update-check/refresh` | Clear cached update data and force a fresh npm update check. | +| POST | `/api/update-check/install` | Install the available package once; a successful install is retained by the old process until restart. | | GET | `/api/updates/check` | Perform an on-demand npm registry check for the latest `@runfusion/fusion` version (no cache). | +Update-check responses may include `pendingInstall`, using the install response shape for a successful `installed` target plus restart flags. `pendingInstall` takes dashboard action/message precedence over ordinary availability, disabled checks, and cached status; while it exists GET, refresh, and install return it without another registry lookup or npm install. It is intentionally process-local and expires on host replacement, rather than being persisted in settings or storage. + When adding a new node settings/auth sync endpoint, add it to the `ENDPOINTS` catalog in `packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts` so the auth/error/payload parity matrix covers it. Inbound sync endpoints (including `/api/secrets/sync-receive` and `/api/secrets/sync-export`) must validate `Authorization: Bearer ` against the local node API key. ### Agent stats endpoint diff --git a/docs/assets/fn-104-pending-update-desktop.png b/docs/assets/fn-104-pending-update-desktop.png new file mode 100644 index 0000000000..3d673b172f Binary files /dev/null and b/docs/assets/fn-104-pending-update-desktop.png differ diff --git a/docs/assets/fn-104-pending-update-mobile.png b/docs/assets/fn-104-pending-update-mobile.png new file mode 100644 index 0000000000..66a840a214 Binary files /dev/null and b/docs/assets/fn-104-pending-update-mobile.png differ diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f854ac4617..17d06dccf8 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -15,7 +15,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett ## Dashboard Updates -When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. Every Update now result remains visible: install success offers **Restart Fusion**, a current version reports no update, failed checks and installs show errors, and unsupported source-checkout, Homebrew, or missing-npm hosts show guidance instead of running a meaningless global install. Settings exposes independent **Automatically install updates** and **Automatically restart after an update** choices for the selected stable or beta channel; the watcher checks about one minute after boot and every six hours. The unattended updater skips unsupported hosts without restarting. When Fusion is unsupervised (for example, started with `--no-supervise`), the restart action remains available so the server can explain the refusal; restart Fusion manually when it cannot be scheduled. +When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. Every Update now result remains visible: install success offers **Restart Fusion**, a current version reports no update, failed checks and installs show errors, and unsupported source-checkout, Homebrew, or missing-npm hosts show guidance instead of running a meaningless global install. After a successful install, the running server retains the pending target until it restarts: closing and reopening Settings, the global update banner, and Command Center continue to show the installed-success state and **Restart Fusion**, never a second install action. Settings exposes independent **Automatically install updates** and **Automatically restart after an update** choices for the selected stable or beta channel; the watcher checks about one minute after boot and every six hours. The unattended updater skips unsupported hosts without restarting. When Fusion is unsupervised (for example, started with `--no-supervise`), the restart action remains available so the server can explain the refusal; restart Fusion manually when it cannot be scheduled. ### Supervised source-checkout rebuilds diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ec13d94eaf..adb1837db6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -295,7 +295,7 @@ Disable daily update checks globally: fn settings set updateCheckEnabled false ``` -When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** uses a pinned global npm install and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. Every request reports an explicit outcome: `installed`, `no-update-available`, `check-failed`, `unsupported-install-method`, or `failed`. A failed registry check is reported as a failure, never as “already up to date”. Source checkouts, Homebrew installs, and hosts without `npm` are refused before installation with actionable guidance; source-checkout auto-update logs a skip and never requests a restart. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version. +When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** uses a pinned global npm install and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. Every request reports an explicit outcome: `installed`, `no-update-available`, `check-failed`, `unsupported-install-method`, or `failed`. A failed registry check is reported as a failure, never as “already up to date”. Source checkouts, Homebrew installs, and hosts without `npm` are refused before installation with actionable guidance; source-checkout auto-update logs a skip and never requests a restart. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version. The old server process retains that successful target as a pending install until process replacement, so reopening Settings or another dashboard update surface keeps **Restart Fusion** available and cannot launch a second installation. This is process-local state, not a saved setting: it naturally clears when Fusion restarts. --- diff --git a/packages/dashboard/app/api/client/health.ts b/packages/dashboard/app/api/client/health.ts index 23d81494c2..f25802aeb3 100644 --- a/packages/dashboard/app/api/client/health.ts +++ b/packages/dashboard/app/api/client/health.ts @@ -16,6 +16,18 @@ export interface UpdateCheckResponse { currentVersion: string; latestVersion: string | null; updateAvailable: boolean; + /** Process-local successful install that remains actionable until host replacement. */ + pendingInstall?: { + currentVersion: string; + latestVersion: string | null; + updated: boolean; + outcome?: "installed" | "no-update-available" | "check-failed" | "unsupported-install-method" | "failed"; + message?: string; + error?: string; + restartAttempted?: boolean; + restartScheduled?: boolean; + priorPid?: number; + }; lastChecked?: number; disabled?: boolean; error?: string; diff --git a/packages/dashboard/app/api/settings/settings.ts b/packages/dashboard/app/api/settings/settings.ts index 0298c58da1..0b63ed2c71 100644 --- a/packages/dashboard/app/api/settings/settings.ts +++ b/packages/dashboard/app/api/settings/settings.ts @@ -54,6 +54,9 @@ export interface UpdateInstallResponse { priorPid?: number; } +/** The old dashboard process retains this successful install until it restarts. */ +export type PendingUpdateInstall = UpdateInstallResponse; + export function installUpdate(projectId?: string): Promise { return api(withProjectId("/update-check/install", projectId), { method: "POST", diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a4d72b9c49..17e479f54c 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -12,6 +12,7 @@ import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettin import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split"; import { systemRestartRecovery, useSystemRestartRecovery } from "../hooks/useSystemRestartRecovery"; +import { pendingUpdateInstallState, usePendingUpdateInstall } from "../hooks/usePendingUpdateInstall"; import { ALL_PROJECT_RESET_KEYS, getResetIneligibleReason, @@ -1255,6 +1256,7 @@ export function SettingsModal({ const [restartScheduled, setRestartScheduled] = useState(false); const [restartError, setRestartError] = useState(null); const restartRecovery = useSystemRestartRecovery(); + const pendingInstall = usePendingUpdateInstall(); const gitHubStarCount = useGitHubStarCount(); const [starClicked, markStarClicked] = useStarClickedFlag(); const [prefixError, setPrefixError] = useState(null); @@ -1803,6 +1805,7 @@ export function SettingsModal({ try { const result = await checkForUpdates(); + pendingUpdateInstallState.record(result.pendingInstall); setUpdateCheckResult(result); if (result.error) { @@ -1831,6 +1834,7 @@ export function SettingsModal({ try { const result = await installUpdate(projectId); + pendingUpdateInstallState.record(result); setUpdateInstallResult(result); if (result.restartScheduled && result.latestVersion) { setRestartScheduled(true); @@ -1924,7 +1928,7 @@ export function SettingsModal({ const result = await requestSystemRestart("settings-update"); if (result.scheduled) { setRestartScheduled(true); - const targetVersion = updateInstallResult?.latestVersion ?? updateCheckResult?.latestVersion; + const targetVersion = pendingInstall?.latestVersion ?? updateInstallResult?.latestVersion ?? updateCheckResult?.latestVersion; if (targetVersion) systemRestartRecovery.arm(targetVersion, restartPriorPid); } else { setRestartError(t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually.")); @@ -1934,27 +1938,32 @@ export function SettingsModal({ } finally { setRestartLoading(false); } - }, [restartLoading, restartPriorPid, t, updateCheckResult, updateInstallResult]); + }, [pendingInstall, restartLoading, restartPriorPid, t, updateCheckResult, updateInstallResult]); const renderUpdateCheckResultContent = useCallback(() => { - if (!updateCheckResult) { + /* FNXC:PendingUpdateInstall 2026-08-21-05:58: A host-retained install takes precedence over this modal's transient check and loading state, including after the modal remounts. */ + const effectiveCheckResult = pendingInstall + ? { currentVersion: pendingInstall.currentVersion, latestVersion: pendingInstall.latestVersion, updateAvailable: true } + : updateCheckResult; + const effectiveInstallResult = pendingInstall ?? updateInstallResult; + if (!effectiveCheckResult) { return null; } - if (updateCheckResult.error) { - return updateCheckResult.error; + if (effectiveCheckResult.error) { + return effectiveCheckResult.error; } - if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) { - const installSucceeded = updateInstallResult?.updated === true; - const installError = updateInstallResult?.error; - const installMessage = updateInstallResult?.message ?? installError ?? (updateInstallResult && !updateInstallResult.updated ? t("settings.general.updateUnknown", "Update did not complete — see the Fusion logs") : undefined); - const installIsError = updateInstallResult?.outcome === "check-failed" || updateInstallResult?.outcome === "failed" || Boolean(installError && updateInstallResult?.outcome !== "unsupported-install-method"); + if (effectiveCheckResult.updateAvailable && effectiveCheckResult.latestVersion) { + const installSucceeded = effectiveInstallResult?.updated === true; + const installError = effectiveInstallResult?.error; + const installMessage = effectiveInstallResult?.message ?? installError ?? (effectiveInstallResult && !effectiveInstallResult.updated ? t("settings.general.updateUnknown", "Update did not complete — see the Fusion logs") : undefined); + const installIsError = effectiveInstallResult?.outcome === "check-failed" || effectiveInstallResult?.outcome === "failed" || Boolean(installError && effectiveInstallResult?.outcome !== "unsupported-install-method"); return ( <> - {t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "} + {t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: effectiveCheckResult.latestVersion })} ·{" "} {t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { - version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion, + version: effectiveInstallResult.latestVersion ?? effectiveCheckResult.latestVersion, })} - {restartScheduled ? ( + {restartScheduled || pendingInstall?.restartScheduled ? ( <> {restartRecovery.phase === "back" @@ -2051,7 +2060,7 @@ export function SettingsModal({ } return t("settings.general.upToDate", "You're up to date ✓"); - }, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartRecovery, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]); + }, [handleInstallUpdate, handleRestart, pendingInstall, restartError, restartLoading, restartRecovery, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]); /* FNXC:SettingsUpdate 2026-07-25-19:40: @@ -2062,13 +2071,16 @@ export function SettingsModal({ Import/Export/Reset/Close were pushed off-screen behind a scroll affordance operators do not see. Giving the banner its own row keeps the rail to the controls it was sized for, and the banner wraps normally instead of clipping. */ - const updateCheckResultNode = updateCheckResult ? ( + const displayedUpdateCheckResult = pendingInstall + ? { currentVersion: pendingInstall.currentVersion, latestVersion: pendingInstall.latestVersion, updateAvailable: true } + : updateCheckResult; + const updateCheckResultNode = displayedUpdateCheckResult ? ( (null); const recovery = useSystemRestartRecovery(); + // Root useUpdateCheck hydrates the shared host snapshot; this consumer only subscribes. + const pendingInstall = usePendingUpdateInstall({ hydrate: false }); useEffect(() => { let active = true; @@ -50,6 +53,7 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss try { const result = await installUpdate(); + pendingUpdateInstallState.record(result); setInstallResult(result); if (result.restartScheduled && result.latestVersion) { setRestartScheduled(true); @@ -87,7 +91,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss const result = await requestSystemRestart("update-banner"); if (result.scheduled) { setRestartScheduled(true); - if (installResult?.latestVersion ?? latestVersion) systemRestartRecovery.arm(installResult?.latestVersion ?? latestVersion, priorPid); + const targetVersion = pendingInstall?.latestVersion ?? installResult?.latestVersion ?? latestVersion; + if (targetVersion) systemRestartRecovery.arm(targetVersion, priorPid); } else { setRestartError(t("updateBanner.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually.")); } @@ -99,10 +104,11 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }; /* FNXC:UpdateBanner 2026-08-14-19:31: an Update now result always remains visible; failed checks are errors, never up-to-date reassurance. */ - const installSucceeded = installResult?.updated === true; - const installError = installResult?.error; - const installMessage = installResult?.message ?? installError ?? (installResult && !installResult.updated ? t("updateBanner.updateUnknown", "Update did not complete — see the Fusion logs") : undefined); - const installIsError = installResult?.outcome === "check-failed" || installResult?.outcome === "failed" || Boolean(installError && installResult?.outcome !== "unsupported-install-method"); + const effectiveInstallResult = pendingInstall ?? installResult; + const installSucceeded = effectiveInstallResult?.updated === true; + const installError = effectiveInstallResult?.error; + const installMessage = effectiveInstallResult?.message ?? installError ?? (effectiveInstallResult && !effectiveInstallResult.updated ? t("updateBanner.updateUnknown", "Update did not complete — see the Fusion logs") : undefined); + const installIsError = effectiveInstallResult?.outcome === "check-failed" || effectiveInstallResult?.outcome === "failed" || Boolean(installError && effectiveInstallResult?.outcome !== "unsupported-install-method"); // Advisory guidance only — shown when the host explicitly reported no supervising parent. const restartUnavailable = restartSupported === false; @@ -134,10 +140,10 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss <> {t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { - version: installResult.latestVersion ?? latestVersion, + version: effectiveInstallResult.latestVersion ?? latestVersion, })} - {restartScheduled ? ( + {restartScheduled || pendingInstall?.restartScheduled ? ( <> {recovery.phase === "back" diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 87fecc2a7a..fc8bcb430c 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -3,6 +3,7 @@ import { act, render, screen, fireEvent, waitFor, within, cleanup } from "@testi import path from "path"; import { SettingsModal } from "../SettingsModal"; import { __test_resetSystemRestartRecovery, systemRestartRecovery } from "../../hooks/useSystemRestartRecovery"; +import { __test_resetPendingUpdateInstall } from "../../hooks/usePendingUpdateInstall"; import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss"; import { mockFetchSettings, @@ -134,6 +135,7 @@ vi.mock("../../api", async (importOriginal) => { fetchProjects: (...args: unknown[]) => mockFetchProjects(...args), fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args), checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), + checkForUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: null, updateAvailable: false })), installUpdate: (...args: unknown[]) => mockInstallUpdate(...args), fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args), requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args), @@ -414,6 +416,9 @@ describe("SettingsModal", () => { } describe("update restart affordance", () => { + beforeEach(() => { + __test_resetPendingUpdateInstall(); + }); it("renders an enabled restart button after a successful update on desktop", async () => { viewportMode = "desktop"; @@ -1263,6 +1268,7 @@ describe("SettingsModal", () => { describe("Global General", () => { beforeEach(() => { + __test_resetPendingUpdateInstall(); localStorage.setItem("fusion:settings:show-advanced", "true"); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index 6f2a9cb7ab..1a961bcb3b 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -5,6 +5,7 @@ import path from "path"; import { SettingsModal } from "../SettingsModal"; import type { SettingsExportData, UpdateCheckResponse } from "../../api"; import { ApiRequestError } from "../../api"; +import { __test_resetPendingUpdateInstall } from "../../hooks/usePendingUpdateInstall"; import { mockFetchSettings, mockFetchSettingsByScope, @@ -122,6 +123,7 @@ vi.mock("../../api", async (importOriginal) => { fetchProjects: (...args: unknown[]) => mockFetchProjects(...args), fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args), checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), + checkForUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: null, updateAvailable: false })), installUpdate: (...args: unknown[]) => mockInstallUpdate(...args), fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args), updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args), @@ -208,6 +210,7 @@ describe("SettingsModal", () => { installSettingsModalEnv(); beforeEach(() => { + __test_resetPendingUpdateInstall(); localStorage.setItem("fusion:settings:show-advanced", "true"); }); @@ -974,6 +977,75 @@ describe("SettingsModal", () => { expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument(); }); + it("preserves the installed restart state after closing and reopening Settings", async () => { + mockCheckForUpdates.mockResolvedValueOnce({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updateAvailable: true, + }); + mockInstallUpdate.mockResolvedValueOnce({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: true, + outcome: "installed", + }); + + const firstModal = renderModal(); + await waitForSettingsModalReady(); + await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" })); + await settingsModalUser.click(await screen.findByRole("button", { name: "Update now" })); + expect(await screen.findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeInTheDocument(); + + firstModal.unmount(); + renderModal(); + await waitForSettingsModalReady(); + + expect(await screen.findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Updating…" })).not.toBeInTheDocument(); + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockInstallUpdate).toHaveBeenCalledTimes(1); + }); + + it("retains a successful install that completes after Settings closes", async () => { + mockCheckForUpdates.mockResolvedValueOnce({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updateAvailable: true, + }); + let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean; outcome: "installed" }) => void) | undefined; + mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => { + resolveInstall = resolve; + })); + + const firstModal = renderModal(); + await waitForSettingsModalReady(); + await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" })); + await settingsModalUser.click(await screen.findByRole("button", { name: "Update now" })); + expect(await screen.findByRole("button", { name: "Updating…" })).toBeDisabled(); + + firstModal.unmount(); + await act(async () => { + resolveInstall?.({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: true, + outcome: "installed", + }); + }); + + renderModal(); + await waitForSettingsModalReady(); + + expect(await screen.findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Updating…" })).not.toBeInTheDocument(); + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockInstallUpdate).toHaveBeenCalledTimes(1); + }); + it("disables update-now and shows inline errors while installing", async () => { mockCheckForUpdates.mockResolvedValueOnce({ currentVersion: "1.0.0", diff --git a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx index f478875859..07ccd2f396 100644 --- a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { useState } from "react"; import { UpdateAvailableBanner } from "../UpdateAvailableBanner"; import { __test_resetSystemRestartRecovery } from "../../hooks/useSystemRestartRecovery"; +import { __test_resetPendingUpdateInstall } from "../../hooks/usePendingUpdateInstall"; const mockFetchDashboardHealth = vi.hoisted(() => vi.fn()); const mockFetchSystemInfo = vi.hoisted(() => vi.fn()); @@ -38,6 +39,7 @@ async function completeInstall() { describe("UpdateAvailableBanner", () => { beforeEach(() => { __test_resetSystemRestartRecovery(); + __test_resetPendingUpdateInstall(); mockFetchDashboardHealth.mockReset(); mockFetchDashboardHealth.mockResolvedValue({ version: "not-ready", status: "starting", holding: true }); mockFetchSystemInfo.mockReset(); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 330fc13c7c..0770733c3b 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SettingsModal, SettingsView } from "../SettingsModal"; +import { __test_resetPendingUpdateInstall } from "../../hooks/usePendingUpdateInstall"; import type { Settings } from "@fusion/core"; @@ -118,6 +119,7 @@ vi.mock("../../api", () => ({ })), fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })), checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })), + checkForUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })), installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })), fetchSystemInfo: vi.fn(() => Promise.resolve({ supervised: true, restartSupported: true })), requestSystemRestart: vi.fn(() => Promise.resolve({ scheduled: true })), @@ -273,6 +275,7 @@ function expectBaseRule(css: string, selector: string, declaration: string): voi describe("SettingsModal mobile adaptations", () => { beforeEach(() => { + __test_resetPendingUpdateInstall(); vi.clearAllMocks(); setDocumentHidden(false); localStorage.removeItem("fusion_github_star_count"); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx index 1d2cd471c5..fee7788a31 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -37,6 +37,7 @@ import { type UpdateCheckResponse, } from "../../../api/legacy"; import { subscribeSse } from "../../../sse-bus"; +import { pendingUpdateInstallState, usePendingUpdateInstall } from "../../../hooks/usePendingUpdateInstall"; import type { ReportActionType } from "@fusion/core"; import type { ToastType } from "../../../hooks/useToast"; import { ReportActionMenu } from "../../ReportActionMenu"; @@ -160,6 +161,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr const logFollowingRef = useRef(true); const [updateCheckResult, setUpdateCheckResult] = useState(null); + // The global update hook hydrates; this panel also records its explicit refresh result. + const pendingInstall = usePendingUpdateInstall({ hydrate: false }); /* FNXC:SystemPanel 2026-07-18-16:12: @@ -544,6 +547,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr () => runAction("check-updates", async () => { const result = await refreshUpdateCheck(); + pendingUpdateInstallState.record(result.pendingInstall); setUpdateCheckResult(result); if (result.error) { toast(result.error, "error"); @@ -940,22 +944,24 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr ) : null} - {updateCheckResult && !updateCheckResult.error ? ( + {(pendingInstall || (updateCheckResult && !updateCheckResult.error)) ? (
- {updateCheckResult.disabled - ? t("systemControls.updatesDisabled", "Update checks are disabled in global settings") - : updateCheckResult.updateAvailable && updateCheckResult.latestVersion + {pendingInstall + ? t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { version: pendingInstall.latestVersion }) + : updateCheckResult?.disabled + ? t("systemControls.updatesDisabled", "Update checks are disabled in global settings") + : updateCheckResult?.updateAvailable && updateCheckResult.latestVersion ? t("systemControls.updateAvailable", "Update available: v{{version}} (current: v{{current}})", { version: updateCheckResult.latestVersion, current: updateCheckResult.currentVersion, }) : t("systemControls.upToDate", "You're up to date (v{{version}})", { - version: updateCheckResult.currentVersion, + version: updateCheckResult?.currentVersion, })}
diff --git a/packages/dashboard/app/hooks/__tests__/usePendingUpdateInstall.test.ts b/packages/dashboard/app/hooks/__tests__/usePendingUpdateInstall.test.ts new file mode 100644 index 0000000000..a5458ae97e --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/usePendingUpdateInstall.test.ts @@ -0,0 +1,44 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { __test_resetPendingUpdateInstall, pendingUpdateInstallState, usePendingUpdateInstall } from "../usePendingUpdateInstall"; +import * as api from "../../api"; + +vi.mock("../../api", () => ({ checkForUpdate: vi.fn() })); +const checkForUpdate = vi.mocked(api.checkForUpdate); +const pending = { currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" as const }; + +describe("pendingUpdateInstallState", () => { + beforeEach(() => { + vi.clearAllMocks(); + __test_resetPendingUpdateInstall(); + }); + + it("hydrates once for simultaneous consumers and retains a successful target across remount", async () => { + checkForUpdate.mockResolvedValue({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true, pendingInstall: pending }); + const first = renderHook(() => usePendingUpdateInstall()); + const second = renderHook(() => usePendingUpdateInstall()); + await waitFor(() => expect(first.result.current).toMatchObject(pending)); + expect(checkForUpdate).toHaveBeenCalledTimes(1); + first.unmount(); + second.unmount(); + const remount = renderHook(() => usePendingUpdateInstall({ hydrate: false })); + expect(remount.result.current).toMatchObject(pending); + }); + + it("keeps a late successful install after an initiating component unmounts and ignores stale empty reads", async () => { + let resolve!: (value: { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }) => void; + checkForUpdate.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + const mounted = renderHook(() => usePendingUpdateInstall()); + mounted.unmount(); + act(() => pendingUpdateInstallState.record(pending)); + await act(async () => { resolve({ currentVersion: "1.0.0", latestVersion: null, updateAvailable: false }); }); + expect(pendingUpdateInstallState.getSnapshot()).toMatchObject(pending); + }); + + it("rejects malformed and unsuccessful payloads", () => { + act(() => pendingUpdateInstallState.record({ updated: true })); + expect(pendingUpdateInstallState.getSnapshot()).toBeUndefined(); + act(() => pendingUpdateInstallState.record({ ...pending, updated: false })); + expect(pendingUpdateInstallState.getSnapshot()).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/app/hooks/usePendingUpdateInstall.ts b/packages/dashboard/app/hooks/usePendingUpdateInstall.ts new file mode 100644 index 0000000000..2a0f715cb9 --- /dev/null +++ b/packages/dashboard/app/hooks/usePendingUpdateInstall.ts @@ -0,0 +1,76 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { checkForUpdate } from "../api"; +import type { UpdateCheckResponse, UpdateInstallResponse } from "../api"; + +type Listener = () => void; +const listeners = new Set(); +let pendingInstall: UpdateInstallResponse | undefined; +let hydration: Promise | undefined; + +function publish(): void { + listeners.forEach((listener) => listener()); +} + +function validPending(value: unknown): value is UpdateInstallResponse { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.updated === true + && candidate.outcome !== "failed" + && candidate.outcome !== "check-failed" + && typeof candidate.currentVersion === "string" + && typeof candidate.latestVersion === "string"; +} + +/** + * FNXC:PendingUpdateInstall 2026-08-21-05:58: + * The browser mirrors the old host's retained install without durable storage. + * A successful result is monotonic for this page: stale empty checks and late + * failures may not replace the restart action before process/page replacement. + */ +export const pendingUpdateInstallState = { + getSnapshot: (): UpdateInstallResponse | undefined => pendingInstall, + subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + record(value: unknown): void { + if (!validPending(value) || pendingInstall) return; + pendingInstall = value; + publish(); + }, + hydrate(): Promise { + if (!hydration) { + hydration = checkForUpdate() + .then((result: UpdateCheckResponse) => { + this.record(result.pendingInstall); + }) + .catch(() => { + // Best effort: an existing success remains authoritative on transport failure. + }) + .finally(() => { hydration = undefined; }); + } + return hydration; + }, +}; + +export function usePendingUpdateInstall(options: { hydrate?: boolean } = {}): UpdateInstallResponse | undefined { + const hydrate = options.hydrate !== false; + const snapshot = useSyncExternalStore( + pendingUpdateInstallState.subscribe, + pendingUpdateInstallState.getSnapshot, + pendingUpdateInstallState.getSnapshot, + ); + + useEffect(() => { + if (hydrate) void pendingUpdateInstallState.hydrate(); + }, [hydrate]); + + return snapshot; +} + +/** Test-only isolation for this module-level browser state. */ +export function __test_resetPendingUpdateInstall(): void { + hydration = undefined; + pendingInstall = undefined; + publish(); +} diff --git a/packages/dashboard/app/hooks/useUpdateCheck.ts b/packages/dashboard/app/hooks/useUpdateCheck.ts index 2fec121f8a..eefeb8ea76 100644 --- a/packages/dashboard/app/hooks/useUpdateCheck.ts +++ b/packages/dashboard/app/hooks/useUpdateCheck.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { checkForUpdate } from "../api"; +import type { UpdateInstallResponse } from "../api"; +import { pendingUpdateInstallState, usePendingUpdateInstall } from "./usePendingUpdateInstall"; const UPDATE_BANNER_DISMISSED_KEY = "kb-update-banner-dismissed"; @@ -9,10 +11,12 @@ export interface UseUpdateCheckResult { currentVersion: string | null; loading: boolean; dismissed: boolean; + pendingInstall?: UpdateInstallResponse; dismiss: () => void; } export function useUpdateCheck(): UseUpdateCheckResult { + const pendingInstall = usePendingUpdateInstall({ hydrate: false }); const [loading, setLoading] = useState(true); const [updateAvailable, setUpdateAvailable] = useState(false); const [latestVersion, setLatestVersion] = useState(null); @@ -27,6 +31,9 @@ export function useUpdateCheck(): UseUpdateCheckResult { void checkForUpdate() .then((result) => { + // Record before ordinary update state so a hydrated pending restart never + // flashes a second Update now action through a competing stale response. + pendingUpdateInstallState.record(result.pendingInstall); if (cancelled || result.disabled) return; setUpdateAvailable(result.updateAvailable === true); @@ -58,6 +65,7 @@ export function useUpdateCheck(): UseUpdateCheckResult { currentVersion, loading, dismissed, + pendingInstall, dismiss, }; } diff --git a/packages/dashboard/src/__tests__/auto-update.test.ts b/packages/dashboard/src/__tests__/auto-update.test.ts index 7da14289cf..415c9a4440 100644 --- a/packages/dashboard/src/__tests__/auto-update.test.ts +++ b/packages/dashboard/src/__tests__/auto-update.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { buildAutoUpdateDeps, runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js"; import type { AutoUpdateDeps } from "../auto-update.js"; +import { UpdateInstallCoordinator } from "../update-install-coordinator.js"; /* FNXC:AutoUpdate 2026-07-25-10:05: @@ -100,6 +101,23 @@ describe("runAutoUpdateCycle", () => { }); }); + it("waits for restart without rechecking after another surface installed an update", async () => { + const coordinator = new UpdateInstallCoordinator(); + await coordinator.install("2.0.0", async () => ({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: true, + outcome: "installed", + })); + const deps = makeDeps({ coordinator }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("restart-waiting"); + + expect(deps.checkForUpdate).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + it("does not install or restart when already up to date", async () => { const deps = makeDeps(); deps.checkForUpdate.mockResolvedValue({ diff --git a/packages/dashboard/src/__tests__/update-install-coordinator.test.ts b/packages/dashboard/src/__tests__/update-install-coordinator.test.ts new file mode 100644 index 0000000000..7eeb72e14b --- /dev/null +++ b/packages/dashboard/src/__tests__/update-install-coordinator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { UpdateInstallCoordinator } from "../update-install-coordinator.js"; + +const installed = { currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" as const }; + +describe("UpdateInstallCoordinator", () => { + it("shares one in-flight install and retains only a successful installed target", async () => { + const coordinator = new UpdateInstallCoordinator(); + let resolve!: (value: typeof installed) => void; + const operation = vi.fn(() => new Promise((done) => { resolve = done; })); + const first = coordinator.install("2.0.0", operation); + const second = coordinator.install("2.0.0", operation); + expect(operation).toHaveBeenCalledTimes(1); + resolve(installed); + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]); + expect(coordinator.getPendingInstall()).toMatchObject({ ...installed, restartScheduled: false }); + await coordinator.install("3.0.0", operation); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("releases a failed install for retry and makes accepted restart idempotent", async () => { + const coordinator = new UpdateInstallCoordinator(); + const failed = { ...installed, updated: false, outcome: "failed" as const }; + await coordinator.install("2.0.0", vi.fn().mockResolvedValue(failed)); + expect(coordinator.getPendingInstall()).toBeUndefined(); + await coordinator.install("2.0.0", vi.fn().mockResolvedValue(installed)); + const request = vi.fn(() => false); + expect(coordinator.requestRestart(request)).toBe(false); + expect(coordinator.getPendingInstall()).toMatchObject({ restartScheduled: false }); + request.mockReturnValue(true); + expect(coordinator.requestRestart(request)).toBe(true); + expect(coordinator.requestRestart(request)).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + expect(coordinator.getPendingInstall()).toMatchObject({ restartAttempted: true, restartScheduled: true }); + }); +}); diff --git a/packages/dashboard/src/auto-update.ts b/packages/dashboard/src/auto-update.ts index 1ab8706864..61c0a45e41 100644 --- a/packages/dashboard/src/auto-update.ts +++ b/packages/dashboard/src/auto-update.ts @@ -50,6 +50,7 @@ export type AutoUpdateOutcome = | "install-failed" | "unsupported-install-method" | "restart-unavailable" + | "restart-waiting" | "restarting"; export interface AutoUpdateLogger { @@ -115,6 +116,16 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise install(result.currentVersion, result.latestVersion, { fusionDir, installMethod: { sourceWorkspaceRoot: deps.sourceWorkspaceRoot }, @@ -168,7 +178,7 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise deps.requestRestart("auto-update")); + const scheduled = coordinator.requestRestart(() => deps.requestRestart("auto-update")); if (!scheduled) { deps.log.warn("Auto-update installed but restart was not scheduled", { message: `v${installed.latestVersion} is installed; restart Fusion manually to run it.`, diff --git a/packages/dashboard/src/routes/__tests__/register-update-check-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-update-check-routes.test.ts index 6f5e05c010..5255275a18 100644 --- a/packages/dashboard/src/routes/__tests__/register-update-check-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-update-check-routes.test.ts @@ -96,6 +96,20 @@ describe("registerUpdateCheckRoutes", () => { expect(response.body).toMatchObject({ outcome }); }); + it("returns a retained pending install for later reads without another check or install", async () => { + mockPerformUpdateCheck.mockResolvedValue(updateAvailable); + mockPerformUpdateInstall.mockResolvedValue({ ...updateAvailable, updated: true, outcome: "installed" }); + const app = createApp(); + expect((await postInstall(app)).body).toMatchObject({ updated: true, latestVersion: "2.0.0" }); + const get = await performRequest(app, "GET", "/api/update-check"); + const refresh = await performRequest(app, "POST", "/api/update-check/refresh", "{}", { "content-type": "application/json" }); + const repeatInstall = await postInstall(app); + for (const response of [get, refresh]) expect(response.body).toMatchObject({ pendingInstall: { updated: true, latestVersion: "2.0.0" } }); + expect(repeatInstall.body).toMatchObject({ updated: true, latestVersion: "2.0.0" }); + expect(mockPerformUpdateCheck).toHaveBeenCalledTimes(1); + expect(mockPerformUpdateInstall).toHaveBeenCalledTimes(1); + }); + it("keeps disabled update checks disabled", async () => { const response = await performRequest(createApp(undefined, false), "GET", "/api/update-check"); expect(response.body).toMatchObject({ disabled: true, updateAvailable: false }); diff --git a/packages/dashboard/src/routes/register-update-check-routes.ts b/packages/dashboard/src/routes/register-update-check-routes.ts index fe216aeefa..7281ad6099 100644 --- a/packages/dashboard/src/routes/register-update-check-routes.ts +++ b/packages/dashboard/src/routes/register-update-check-routes.ts @@ -10,8 +10,26 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => { /* FNXC:UpdateInstall 2026-08-21-02:48: A wired host shares its HTTP and watcher fence so a manual click cannot race the periodic installer. Isolated route harnesses intentionally get a fresh coordinator. */ const coordinator = ctx.options?.systemControl ? processUpdateInstallCoordinator : new UpdateInstallCoordinator(); + const pendingResponse = () => { + const pendingInstall = coordinator.getPendingInstall(); + if (!pendingInstall) return undefined; + return { + currentVersion: pendingInstall.currentVersion, + latestVersion: pendingInstall.latestVersion, + // Keep the old version's availability signal compatible for legacy readers. + updateAvailable: true, + pendingInstall, + lastChecked: Date.now(), + }; + }; + router.get("/update-check", async (_req, res) => { try { + const pending = pendingResponse(); + if (pending) { + res.json(pending); + return; + } const globalSettings = await store.getGlobalSettingsStore().getSettings(); if (globalSettings.updateCheckEnabled === false) { res.json({ @@ -36,6 +54,11 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => { router.post("/update-check/refresh", async (_req, res) => { try { + const pending = pendingResponse(); + if (pending) { + res.json(pending); + return; + } const globalSettings = await store.getGlobalSettingsStore().getSettings(); const fusionDir = resolveGlobalDir(); await clearUpdateCheckCache(fusionDir); @@ -53,6 +76,11 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => { router.post("/update-check/install", async (_req, res) => { try { + const pending = coordinator.getPendingInstall(); + if (pending) { + res.json(pending); + return; + } const globalSettings = await store.getGlobalSettingsStore().getSettings(); const fusionDir = resolveGlobalDir(); const updateCheck = await performUpdateCheck(fusionDir, cliPackageVersion, { @@ -97,7 +125,8 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => { const restartScheduled = restartAttempted ? coordinator.requestRestart(() => ctx.options?.systemControl?.requestRestart("update-install") === true) : false; - res.json({ ...result, restartAttempted, restartScheduled, priorPid: restartAttempted ? process.pid : undefined }); + // FNXC:PendingUpdateInstall 2026-08-21-05:58: Publish the coordinator snapshot after the restart decision so later route reads retain both the target and idempotent restart state. + res.json(coordinator.getPendingInstall() ?? { ...result, restartAttempted, restartScheduled, priorPid: restartAttempted ? process.pid : undefined }); } catch (error) { rethrowAsApiError(error, "Failed to install update"); } diff --git a/packages/dashboard/src/update-install-coordinator.ts b/packages/dashboard/src/update-install-coordinator.ts index 2870e80b92..ea5371b005 100644 --- a/packages/dashboard/src/update-install-coordinator.ts +++ b/packages/dashboard/src/update-install-coordinator.ts @@ -5,12 +5,35 @@ import type { UpdateInstallResult } from "./update-check.js"; * retains a successfully installed target until this process exits, because an * old process must never reinstall files that are waiting for its restart. */ +export type PendingUpdateInstall = UpdateInstallResult & { + restartAttempted: boolean; + restartScheduled: boolean; + priorPid?: number; +}; + export class UpdateInstallCoordinator { private inFlight: Promise | undefined; private pendingVersion: string | undefined; private pendingResult: UpdateInstallResult | undefined; private restartRequested = false; + /** + * FNXC:PendingUpdateInstall 2026-08-21-05:58: + * A completed install belongs to the still-running old process, not a mounted + * Settings dialog. Expose its target until process replacement so every route + * can reject a second install and every dashboard remount can offer restart. + */ + getPendingInstall(): PendingUpdateInstall | undefined { + if (!this.pendingVersion || !this.pendingResult) return undefined; + return { + ...this.pendingResult, + latestVersion: this.pendingVersion, + restartAttempted: this.restartRequested, + restartScheduled: this.restartRequested, + priorPid: this.restartRequested ? process.pid : undefined, + }; + } + async install(targetVersion: string, operation: () => Promise): Promise { if (this.pendingVersion && this.pendingResult) return this.pendingResult; if (!this.inFlight) {