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:
7
.changeset/fn-104-pending-update-restart.md
Normal file
7
.changeset/fn-104-pending-update-restart.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
BIN
docs/assets/fn-104-pending-update-desktop.png
Normal file
BIN
docs/assets/fn-104-pending-update-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/assets/fn-104-pending-update-mobile.png
Normal file
BIN
docs/assets/fn-104-pending-update-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
76
packages/dashboard/app/hooks/usePendingUpdateInstall.ts
Normal file
76
packages/dashboard/app/hooks/usePendingUpdateInstall.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user