From b2125aeb2d770122da48e4210ce7bcdf4cea0c0d Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Fri, 21 Aug 2026 06:40:41 +0000 Subject: [PATCH] FN-104: preserve update restart state Preserve a pending update across Settings reopen while coordinating installation and restart behavior. - Add pending-update install state and coordinator flows across dashboard and system controls. - Expose health/settings update APIs and retain restart state through modal reopen. - Cover desktop/mobile UX, route behavior, coordinator logic, and document the update flow. Files changed: .changeset/fn-104-pending-update-restart.md | 7 ++ docs/architecture.md | 3 + docs/assets/fn-104-pending-update-desktop.png | Bin 0 -> 10926 bytes docs/assets/fn-104-pending-update-mobile.png | Bin 0 -> 6101 bytes docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 2 +- packages/core/src/task-store/task-creation.ts | 1 - packages/dashboard/app/api/client/health.ts | 12 ++++ packages/dashboard/app/api/settings/settings.ts | 3 + .../dashboard/app/components/SettingsModal.tsx | 46 ++++++++----- .../app/components/UpdateAvailableBanner.tsx | 20 ++++-- .../__tests__/SettingsModal.general.test.tsx | 6 ++ .../__tests__/SettingsModal.models-auth.test.tsx | 72 +++++++++++++++++++ .../__tests__/UpdateAvailableBanner.test.tsx | 2 + .../components/__tests__/settings-mobile.test.tsx | 3 + .../command-center/areas/SystemControlsArea.tsx | 18 +++-- .../__tests__/usePendingUpdateInstall.test.ts | 44 ++++++++++++ .../dashboard/app/hooks/usePendingUpdateInstall.ts | 76 +++++++++++++++++++++ packages/dashboard/app/hooks/useUpdateCheck.ts | 8 +++ .../dashboard/src/__tests__/auto-update.test.ts | 18 +++++ .../__tests__/update-install-coordinator.test.ts | 36 ++++++++++ packages/dashboard/src/auto-update.ts | 14 +++- .../__tests__/register-update-check-routes.test.ts | 14 ++++ .../src/routes/register-update-check-routes.ts | 31 ++++++++- .../dashboard/src/update-install-coordinator.ts | 23 +++++++ 25 files changed, 425 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-104 Fusion-Task-Lineage: c754b258-654d-4efe-9b12-1addc7ed190b Co-authored-by: Fusion --- .changeset/fn-104-pending-update-restart.md | 7 ++ docs/architecture.md | 3 + docs/assets/fn-104-pending-update-desktop.png | Bin 0 -> 10926 bytes docs/assets/fn-104-pending-update-mobile.png | Bin 0 -> 6101 bytes docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 2 +- packages/dashboard/app/api/client/health.ts | 12 +++ .../dashboard/app/api/settings/settings.ts | 3 + .../app/components/SettingsModal.tsx | 46 +++++++---- .../app/components/UpdateAvailableBanner.tsx | 20 +++-- .../__tests__/SettingsModal.general.test.tsx | 6 ++ .../SettingsModal.models-auth.test.tsx | 72 +++++++++++++++++ .../__tests__/UpdateAvailableBanner.test.tsx | 2 + .../__tests__/settings-mobile.test.tsx | 3 + .../areas/SystemControlsArea.tsx | 18 +++-- .../__tests__/usePendingUpdateInstall.test.ts | 44 ++++++++++ .../app/hooks/usePendingUpdateInstall.ts | 76 ++++++++++++++++++ .../dashboard/app/hooks/useUpdateCheck.ts | 8 ++ .../src/__tests__/auto-update.test.ts | 18 +++++ .../update-install-coordinator.test.ts | 36 +++++++++ packages/dashboard/src/auto-update.ts | 14 +++- .../register-update-check-routes.test.ts | 14 ++++ .../routes/register-update-check-routes.ts | 31 ++++++- .../src/update-install-coordinator.ts | 23 ++++++ 24 files changed, 425 insertions(+), 35 deletions(-) create mode 100644 .changeset/fn-104-pending-update-restart.md create mode 100644 docs/assets/fn-104-pending-update-desktop.png create mode 100644 docs/assets/fn-104-pending-update-mobile.png create mode 100644 packages/dashboard/app/hooks/__tests__/usePendingUpdateInstall.test.ts create mode 100644 packages/dashboard/app/hooks/usePendingUpdateInstall.ts create mode 100644 packages/dashboard/src/__tests__/update-install-coordinator.test.ts 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 0000000000000000000000000000000000000000..3d673b172fc9d4493da1a533ba87a2b3bf49c4c5 GIT binary patch literal 10926 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 kwt@2xlXd?xDq3#?dF^kN^zbSI { 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) {