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 <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 06:40:41 +00:00
parent 2bdc4448ef
commit b2125aeb2d
24 changed files with 425 additions and 35 deletions

View File

@@ -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.

View File

@@ -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 <apiKey>` against the local node API key.
### Agent stats endpoint

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -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

View File

@@ -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.
---

View File

@@ -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;

View File

@@ -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<UpdateInstallResponse> {
return api<UpdateInstallResponse>(withProjectId("/update-check/install", projectId), {
method: "POST",

View File

@@ -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<string | null>(null);
const restartRecovery = useSystemRestartRecovery();
const pendingInstall = usePendingUpdateInstall();
const gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(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 (
<>
<span>
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: effectiveCheckResult.latestVersion })} ·{" "}
<a
href="https://runfusion.ai"
target="_blank"
@@ -1968,10 +1977,10 @@ export function SettingsModal({
<span className="settings-update-install-succeeded">
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
version: effectiveInstallResult.latestVersion ?? effectiveCheckResult.latestVersion,
})}
</span>
{restartScheduled ? (
{restartScheduled || pendingInstall?.restartScheduled ? (
<>
<span className="settings-update-install-status" aria-live="polite">
{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 ? (
<span
aria-live="polite"
className={`settings-update-result ${
updateCheckResult.error
displayedUpdateCheckResult.error
? "settings-update-result--error"
: updateCheckResult.updateAvailable
: displayedUpdateCheckResult.updateAvailable
? "settings-update-result--available"
: "settings-update-result--up-to-date"
}`}

View File

@@ -6,6 +6,7 @@ import { getErrorMessage } from "@fusion/core";
import { fetchSystemInfo, installUpdate, requestSystemRestart } from "../api";
import type { UpdateInstallResponse } from "../api";
import { systemRestartRecovery, useSystemRestartRecovery } from "../hooks/useSystemRestartRecovery";
import { pendingUpdateInstallState, usePendingUpdateInstall } from "../hooks/usePendingUpdateInstall";
interface UpdateAvailableBannerProps {
latestVersion: string;
@@ -23,6 +24,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
const [restartScheduled, setRestartScheduled] = useState(false);
const [restartError, setRestartError] = useState<string | null>(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
<>
<span className="update-available-banner__install-status update-available-banner__install-status--success" aria-live="polite">
{t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: installResult.latestVersion ?? latestVersion,
version: effectiveInstallResult.latestVersion ?? latestVersion,
})}
</span>
{restartScheduled ? (
{restartScheduled || pendingInstall?.restartScheduled ? (
<>
<span className="update-available-banner__install-status" aria-live="polite">
{recovery.phase === "back"

View File

@@ -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");
});

View File

@@ -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",

View File

@@ -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();

View File

@@ -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");

View File

@@ -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<UpdateCheckResponse | null>(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
</div>
) : null}
{updateCheckResult && !updateCheckResult.error ? (
{(pendingInstall || (updateCheckResult && !updateCheckResult.error)) ? (
<div
className={`cc-syscontrols-banner ${updateCheckResult.updateAvailable ? "cc-syscontrols-banner--back" : ""}`}
className={`cc-syscontrols-banner ${pendingInstall || updateCheckResult?.updateAvailable ? "cc-syscontrols-banner--back" : ""}`}
role="status"
data-testid="cc-system-update-check-result"
>
<span>
{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,
})}
</span>
</div>

View File

@@ -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();
});
});

View File

@@ -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<Listener>();
let pendingInstall: UpdateInstallResponse | undefined;
let hydration: Promise<void> | 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<UpdateInstallResponse>;
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<void> {
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();
}

View File

@@ -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<string | null>(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,
};
}

View File

@@ -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({

View File

@@ -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<typeof installed>((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 });
});
});

View File

@@ -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<AutoUpda
return "unsupervised";
}
const coordinator = deps.coordinator ?? new UpdateInstallCoordinator();
/*
* FNXC:AutoUpdate 2026-08-21-06:28:
* A manual install retained by this old process is authoritative until restart.
* The watcher must wait for that restart before its forced registry check, so
* background automation cannot re-check or reinstall an already-installed target.
*/
if (coordinator.getPendingInstall()) return "restart-waiting";
const fusionDir = deps.fusionDir ?? resolveGlobalDir();
const check = deps.checkForUpdate ?? performUpdateCheck;
const install = deps.installUpdate ?? performUpdateInstall;
@@ -144,7 +155,6 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise<AutoUpda
let installed: UpdateInstallResult;
try {
const coordinator = deps.coordinator ?? new UpdateInstallCoordinator();
installed = await coordinator.install(result.latestVersion, () => install(result.currentVersion, result.latestVersion, {
fusionDir,
installMethod: { sourceWorkspaceRoot: deps.sourceWorkspaceRoot },
@@ -168,7 +178,7 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise<AutoUpda
let latestSettings: AutoUpdateSettings;
try { latestSettings = await deps.getSettings(); } catch { latestSettings = {}; }
if (!resolveUpdateAutomationSettings(latestSettings).autoRestartAfterUpdate) return "up-to-date";
const scheduled = (deps.coordinator ?? new UpdateInstallCoordinator()).requestRestart(() => 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.`,

View File

@@ -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 });

View File

@@ -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");
}

View File

@@ -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<UpdateInstallResult> | 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<UpdateInstallResult>): Promise<UpdateInstallResult> {
if (this.pendingVersion && this.pendingResult) return this.pendingResult;
if (!this.inFlight) {