FN-9042: fix dashboard update installation feedback

Make dashboard update actions report clear terminal outcomes and avoid unsupported installs.

- Distinguish update checks, no-op responses, installation failures, and unsupported installation methods.
- Block source-checkout, Homebrew, and missing-npm global installation attempts with actionable guidance.
- Surface update results in dashboard controls and cover API, updater, and UI behavior with tests.

Files changed:
 .changeset/fn-9042-update-now-outcomes.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/dashboard/app/api/settings/settings.ts    |   2 +
 .../dashboard/app/components/SettingsModal.tsx     |  18 +--
 .../app/components/UpdateAvailableBanner.tsx       |  11 +-
 .../__tests__/SettingsModal.general.test.tsx       |  20 ++++
 .../__tests__/UpdateAvailableBanner.test.tsx       |  19 ++++
 .../dashboard/src/__tests__/auto-update.test.ts    |  38 ++++++-
 .../dashboard/src/__tests__/update-check.test.ts   |  19 +++-
 packages/dashboard/src/auto-update.ts              |  33 +++++-
 .../__tests__/register-update-check-routes.test.ts | 104 ++++++++++++++++++
 .../src/routes/register-update-check-routes.ts     |  28 +++--
 packages/dashboard/src/server.ts                   |   9 +-
 packages/dashboard/src/update-check.ts             | 122 ++++++++++-----------
 15 files changed, 338 insertions(+), 96 deletions(-)

Fusion-Task-Id: FN-9042

Fusion-Task-Lineage: 22ae5540-e5b5-43cf-ad16-c7aa05782593

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 12:59:23 -07:00
parent b7604a94bf
commit ba643fc384
15 changed files with 338 additions and 96 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show clear outcomes when dashboard updates cannot install.
category: fix
dev: Distinguishes failed checks from no-op updates and skips unsupported hosts.

View File

@@ -13,7 +13,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. **Update now** installs the latest global package with npm; after it succeeds, both the Settings update-success state and dashboard update banner offer a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), either action remains disabled and explains that Fusion must be restarted manually.
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. The unattended updater likewise 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

@@ -279,7 +279,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** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. 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.
---

View File

@@ -45,6 +45,8 @@ export interface UpdateInstallResponse {
currentVersion: string;
latestVersion: string | null;
updated: boolean;
outcome?: "installed" | "no-update-available" | "check-failed" | "unsupported-install-method" | "failed";
message?: string;
error?: string;
}

View File

@@ -1773,11 +1773,6 @@ export function SettingsModal({
const result = await installUpdate(projectId);
setUpdateInstallResult(result);
if (result.error) {
addToast(result.error, "error");
return;
}
if (result.updated) {
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success");
/*
@@ -1791,6 +1786,9 @@ export function SettingsModal({
.catch(() => {
// Keep whatever the mount probe resolved; the guidance text covers it.
});
} else {
const message = result.message ?? result.error ?? t("settings.general.updateUnknown", "Update did not complete — see the Fusion logs");
addToast(message, result.outcome === "check-failed" || result.outcome === "failed" || Boolean(result.error) ? "error" : "info");
}
} catch (error) {
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
@@ -1799,6 +1797,8 @@ export function SettingsModal({
latestVersion: updateCheckResult?.latestVersion ?? null,
updated: false,
error: message,
outcome: "failed",
message,
});
addToast(message, "error");
} finally {
@@ -1876,6 +1876,8 @@ export function SettingsModal({
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");
return (
<>
@@ -1960,9 +1962,9 @@ export function SettingsModal({
)}
</button>
)}
{installError && (
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
{t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
{installMessage && !installSucceeded && (
<span className={`settings-update-install-status${installIsError ? " settings-update-install-status--error" : ""}`} aria-live="polite">
{installIsError ? t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage}
</span>
)}
</>

View File

@@ -50,6 +50,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
latestVersion,
updated: false,
error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
outcome: "failed",
message: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
});
} finally {
setInstallLoading(false);
@@ -84,8 +86,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");
// Advisory guidance only — shown when the host explicitly reported no supervising parent.
const restartUnavailable = restartSupported === false;
@@ -176,9 +181,9 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
)}
</button>
)}
{installError && (
<span className="update-available-banner__install-status update-available-banner__install-status--error" aria-live="polite">
{t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
{installMessage && !installSucceeded && (
<span className={`update-available-banner__install-status${installIsError ? " update-available-banner__install-status--error" : ""}`} aria-live="polite">
{installIsError ? t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage}
</span>
)}
</div>

View File

@@ -427,6 +427,26 @@ describe("SettingsModal", () => {
expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument();
});
it.each([
["installed", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true, outcome: "installed" }, /Updated to v2\.0\.0/, true],
["no-update-available", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "no-update-available", message: "Fusion is already up to date." }, /already up to date/i, false],
["check-failed", { currentVersion: "1.2.3", latestVersion: null, updated: false, outcome: "check-failed", error: "registry unavailable", message: "Could not check for updates: registry unavailable" }, /Update failed: Could not check for updates: registry unavailable/, false],
["unsupported-install-method", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "unsupported-install-method", message: "Use pull and rebuild for this source checkout." }, /pull and rebuild/i, false],
["failed", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }, /Update failed: npm failed/, false],
])("renders the %s install outcome in the Settings live status", async (_outcome, response, expected, restarts) => {
mockCheckForUpdates.mockResolvedValue(availableUpdate);
mockInstallUpdate.mockResolvedValue(response);
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
await settingsModalUser.click(await screen.findByRole("button", { name: "Update now" }));
const status = await screen.findByText(expected);
expect(status).toHaveAttribute("aria-live", "polite");
if (_outcome === "check-failed") expect(status).not.toHaveTextContent(/up to date/i);
expect(screen.queryByRole("button", { name: "Restart Fusion" })).toBe(restarts ? screen.getByRole("button", { name: "Restart Fusion" }) : null);
});
it("clears stale unsupported guidance by re-probing after a successful install", async () => {
// Mount probe fails (fails closed to "unsupported"); the post-install re-probe
// proves the host is supervised after all.

View File

@@ -204,6 +204,25 @@ describe("UpdateAvailableBanner", () => {
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
});
it.each([
["installed", { ...successfulInstall, outcome: "installed" }, /Updated to v0\.7\.0/],
["no-update-available", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "no-update-available", message: "Fusion is already up to date." }, /already up to date/i],
["check-failed", { currentVersion: "0.6.0", latestVersion: null, updated: false, outcome: "check-failed", error: "registry unavailable", message: "Could not check for updates: registry unavailable" }, /Could not check for updates: registry unavailable/],
["unsupported-install-method", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "unsupported-install-method", message: "Use pull and rebuild for this source checkout." }, /pull and rebuild/i],
["failed", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }, /Update failed: npm failed/],
["legacy fallback", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false }, /Update did not complete — see the Fusion logs/],
])("renders the %s install outcome in a live status", async (_outcome, response, expected) => {
mockInstallUpdate.mockResolvedValueOnce(response);
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
const status = await screen.findByText(expected);
expect(status).toHaveAttribute("aria-live", "polite");
if (_outcome === "check-failed") expect(status).not.toHaveTextContent(/up to date/i);
if (_outcome === "installed") expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeInTheDocument();
});
it.each([
["supported", true],
["unsupported", false],

View File

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js";
import { buildAutoUpdateDeps, runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js";
import type { AutoUpdateDeps } from "../auto-update.js";
/*
@@ -43,7 +44,7 @@ describe("runAutoUpdateCycle", () => {
await expect(runAutoUpdateCycle(deps)).resolves.toBe("restarting");
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir });
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir, installMethod: { sourceWorkspaceRoot: undefined } });
expect(deps.requestRestart).toHaveBeenCalledWith("auto-update");
});
@@ -126,6 +127,20 @@ describe("runAutoUpdateCycle", () => {
expect(deps.requestRestart).not.toHaveBeenCalled();
});
it("does not restart when the install discovers no update remains", async () => {
const deps = makeDeps();
deps.installUpdate.mockResolvedValue({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updated: false,
outcome: "no-update-available",
message: "Fusion is already up to date.",
});
await expect(runAutoUpdateCycle(deps)).resolves.toBe("up-to-date");
expect(deps.requestRestart).not.toHaveBeenCalled();
});
it("reports a rejected install without throwing", async () => {
const deps = makeDeps();
deps.installUpdate.mockRejectedValue(new Error("EACCES"));
@@ -223,4 +238,23 @@ describe("startAutoUpdateWatcher", () => {
releaseInstall?.();
stop();
});
it("skips unsupported source-checkout installs without restart", async () => {
const deps = makeDeps({ sourceWorkspaceRoot: "/repo/fusion" });
deps.installUpdate.mockResolvedValue({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: false, outcome: "unsupported-install-method", message: "source checkout" });
await expect(runAutoUpdateCycle(deps)).resolves.toBe("unsupported-install-method");
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir, installMethod: { sourceWorkspaceRoot: "/repo/fusion" } });
expect(deps.requestRestart).not.toHaveBeenCalled();
});
it("ratchets production watcher wiring through buildAutoUpdateDeps", () => {
const serverSource = readFileSync(new URL("../server.ts", import.meta.url), "utf8");
expect(serverSource).toContain("startAutoUpdateWatcher(buildAutoUpdateDeps(");
expect(serverSource).not.toMatch(/startAutoUpdateWatcher\(\s*\{\s*getSettings:/s);
});
it("buildAutoUpdateDeps preserves host system-control context", () => { const systemControl = { supervised: true, requestRestart: vi.fn(), sourceWorkspaceRoot: "/repo/fusion" };
const deps = buildAutoUpdateDeps({ getSettings: async () => ({}), currentVersion: "1.0.0", systemControl, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } });
expect(deps).toMatchObject({ supervised: true, requestRestart: systemControl.requestRestart, sourceWorkspaceRoot: "/repo/fusion" });
});
});

View File

@@ -197,7 +197,7 @@ describe("update-check", () => {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" });
expect(existsSync(cachePath)).toBe(false);
});
@@ -215,7 +215,7 @@ describe("update-check", () => {
expect(execFake).toHaveBeenCalledTimes(2);
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@2.0.0", expect.any(Object));
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@2.0.0", expect.any(Object));
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" });
});
it("performUpdateInstall returns an error result for non-collision install failures", async () => {
@@ -225,6 +225,8 @@ describe("update-check", () => {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updated: false,
outcome: "failed",
message: "registry down",
error: "registry down",
});
expect(execFake).toHaveBeenCalledTimes(1);
@@ -245,6 +247,8 @@ describe("update-check", () => {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updated: false,
outcome: "failed",
message: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
error: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
});
expect(result.error).toContain("npm install -g @runfusion/fusion@2.0.0");
@@ -623,4 +627,15 @@ describe("update-check", () => {
expect(result.error).toContain("No valid update target version");
});
});
it("refuses source checkouts and missing npm before meaningful install work", async () => {
const execFake = vi.fn();
const source = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir, installMethod: { sourceWorkspaceRoot: "/repo/fusion" } });
expect(source.outcome).toBe("unsupported-install-method");
expect(source.message).toContain("source checkout");
expect(execFake).not.toHaveBeenCalled();
const missing = await performUpdateInstall("1.0.0", "2.0.0", { exec: vi.fn().mockRejectedValue(Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" })), fusionDir });
expect(missing.outcome).toBe("unsupported-install-method");
expect(missing.message).toContain("npm");
});
});

View File

@@ -45,6 +45,7 @@ export type AutoUpdateOutcome =
| "up-to-date"
| "check-failed"
| "install-failed"
| "unsupported-install-method"
| "restart-unavailable"
| "restarting";
@@ -63,11 +64,29 @@ export interface AutoUpdateDeps {
requestRestart: (reason: string) => boolean;
log: AutoUpdateLogger;
fusionDir?: string;
/** Host-injected checkout root: global npm cannot update this running program. */
sourceWorkspaceRoot?: string;
/** Test seams. */
checkForUpdate?: typeof performUpdateCheck;
installUpdate?: typeof performUpdateInstall;
}
/*
FNXC:AutoUpdate 2026-08-14-19:31:
Only the dashboard host knows whether Fusion runs from a source checkout. Keep
that context in this exported builder so unattended installs cannot restart a
program that a global npm install could never change.
*/
export function buildAutoUpdateDeps(input: {
getSettings: () => Promise<AutoUpdateSettings>;
currentVersion: string;
systemControl: { supervised: boolean; requestRestart: (reason: string) => boolean; sourceWorkspaceRoot?: string };
log: AutoUpdateLogger;
fusionDir?: string;
}): AutoUpdateDeps {
return { getSettings: input.getSettings, currentVersion: input.currentVersion, supervised: input.systemControl.supervised, requestRestart: input.systemControl.requestRestart, sourceWorkspaceRoot: input.systemControl.sourceWorkspaceRoot, log: input.log, fusionDir: input.fusionDir };
}
/**
* Run one auto-update cycle. Exported for tests and for callers that want a
* single deterministic pass instead of the timer loop.
@@ -120,14 +139,22 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise<AutoUpda
let installed: UpdateInstallResult;
try {
installed = await install(result.currentVersion, result.latestVersion, { fusionDir });
installed = await install(result.currentVersion, result.latestVersion, {
fusionDir,
installMethod: { sourceWorkspaceRoot: deps.sourceWorkspaceRoot },
});
} catch (error) {
deps.log.error("Auto-update install failed", { message: errorMessage(error) });
return "install-failed";
}
if (!installed.updated) {
deps.log.error("Auto-update install failed", { message: installed.error ?? "unknown install failure" });
if (installed.outcome === "unsupported-install-method") {
deps.log.warn("Auto-update skipped: this host cannot be updated by a global npm install", { message: installed.message });
return "unsupported-install-method";
}
if (installed.outcome === "no-update-available") return "up-to-date";
if ((installed.outcome && installed.outcome !== "installed") || !installed.updated) {
deps.log.error("Auto-update install failed", { message: installed.error ?? installed.message ?? "unknown install failure" });
return "install-failed";
}

View File

@@ -0,0 +1,104 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { registerUpdateCheckRoutes } from "../register-update-check-routes.js";
const mockPerformUpdateCheck = vi.hoisted(() => vi.fn());
const mockPerformUpdateInstall = vi.hoisted(() => vi.fn());
vi.mock("../../update-check.js", () => ({
clearUpdateCheckCache: vi.fn(),
performUpdateCheck: (...args: unknown[]) => mockPerformUpdateCheck(...args),
performUpdateInstall: (...args: unknown[]) => mockPerformUpdateInstall(...args),
}));
vi.mock("../../cli-package-version.js", () => ({ getCliPackageVersion: () => "1.2.3" }));
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return { ...actual, resolveGlobalDir: () => "/tmp/fusion-update-route-test" };
});
function createApp(sourceWorkspaceRoot?: string, updateCheckEnabled?: boolean) {
const router = express.Router();
registerUpdateCheckRoutes({
router,
store: { getGlobalSettingsStore: () => ({ getSettings: async () => ({ updateCheckEnabled }) }) } as never,
options: sourceWorkspaceRoot ? { systemControl: { sourceWorkspaceRoot } } : undefined,
rethrowAsApiError: (error: unknown) => { throw error; },
} as never);
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(500).json({ error: error.message });
});
return app;
}
async function postInstall(app: ReturnType<typeof createApp>) {
return performRequest(app, "POST", "/api/update-check/install", "{}", { "content-type": "application/json" });
}
const updateAvailable = { currentVersion: "1.2.3", latestVersion: "2.0.0", updateAvailable: true, lastChecked: 0 };
/*
FNXC:UpdateInstall 2026-08-14-19:44:
The production registrar is the symptom boundary: it must preserve failed re-checks
instead of returning the old indistinguishable `{ updated: false }` body.
*/
describe("registerUpdateCheckRoutes", () => {
beforeEach(() => {
mockPerformUpdateCheck.mockReset();
mockPerformUpdateInstall.mockReset();
});
it("returns a visible no-update outcome without installing", async () => {
mockPerformUpdateCheck.mockResolvedValue({ ...updateAvailable, updateAvailable: false, latestVersion: "1.2.3" });
const response = await postInstall(createApp());
expect(response.body).toMatchObject({ outcome: "no-update-available", updated: false });
expect(response.body.message).toMatch(/already up to date/i);
expect(response.body.error).toBeUndefined();
expect(mockPerformUpdateInstall).not.toHaveBeenCalled();
});
it.each([
["registry failure", { currentVersion: "1.2.3", latestVersion: null, updateAvailable: false, error: "fetch failed" }, /fetch failed/i],
["unavailable version sentinel", { currentVersion: "0.0.0", latestVersion: null, updateAvailable: false, error: "Current Fusion version is unavailable" }, /Current Fusion version is unavailable/i],
["unresolved latest version", { currentVersion: "1.2.3", latestVersion: null, updateAvailable: false }, /could not determine/i],
])("reports %s as check-failed without installing", async (_name, result, message) => {
mockPerformUpdateCheck.mockResolvedValue(result);
const response = await postInstall(createApp());
expect(response.body).toMatchObject({ outcome: "check-failed", updated: false });
expect(response.body.message).toMatch(message);
expect(response.body.error).toMatch(message);
expect(response.body.message).not.toMatch(/already up to date/i);
expect(mockPerformUpdateInstall).not.toHaveBeenCalled();
});
it("forwards the source checkout root and returns the helper outcome", async () => {
mockPerformUpdateCheck.mockResolvedValue(updateAvailable);
mockPerformUpdateInstall.mockResolvedValue({ ...updateAvailable, updated: false, outcome: "unsupported-install-method", message: "This Fusion is running from a source checkout" });
const response = await postInstall(createApp("/repo/fusion"));
expect(mockPerformUpdateInstall).toHaveBeenCalledWith("1.2.3", "2.0.0", expect.objectContaining({ installMethod: { sourceWorkspaceRoot: "/repo/fusion" } }));
expect(response.body).toMatchObject({ outcome: "unsupported-install-method", updated: false });
});
it.each([
["installed", { ...updateAvailable, updated: true, outcome: "installed" }],
["failed", { ...updateAvailable, updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }],
])("returns the %s install outcome", async (outcome, installResult) => {
mockPerformUpdateCheck.mockResolvedValue(updateAvailable);
mockPerformUpdateInstall.mockResolvedValue(installResult);
const response = await postInstall(createApp());
expect(response.body).toMatchObject({ outcome });
});
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 });
expect(mockPerformUpdateCheck).not.toHaveBeenCalled();
});
});

View File

@@ -57,16 +57,30 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
channel: globalSettings.updateChannel,
});
if (!updateCheck.updateAvailable || !updateCheck.latestVersion) {
res.json({
currentVersion: updateCheck.currentVersion,
latestVersion: updateCheck.latestVersion,
updated: false,
});
/*
FNXC:UpdateInstall 2026-08-14-19:31:
Every terminal response has an outcome: the old early return discarded a
failed re-check and made it look like a successful no-op to both clients.
*/
if (updateCheck.error?.trim()) {
const message = `Could not check for updates: ${updateCheck.error}`;
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: updateCheck.latestVersion, updated: false, outcome: "check-failed", error: updateCheck.error, message });
return;
}
if (!updateCheck.latestVersion) {
const message = "Could not determine the latest published Fusion version.";
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: null, updated: false, outcome: "check-failed", error: message, message });
return;
}
if (!updateCheck.updateAvailable) {
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: updateCheck.latestVersion, updated: false, outcome: "no-update-available", message: `Fusion is already up to date at v${updateCheck.currentVersion}.` });
return;
}
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, { fusionDir });
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, {
fusionDir,
installMethod: { sourceWorkspaceRoot: ctx.options?.systemControl?.sourceWorkspaceRoot },
});
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to install update");

View File

@@ -77,7 +77,7 @@ import type { CliRelaunchRegistry } from "./cli-session-transport.js";
import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js";
import { performUpdateCheck } from "./update-check.js";
import { startAutoUpdateWatcher } from "./auto-update.js";
import { buildAutoUpdateDeps, startAutoUpdateWatcher } from "./auto-update.js";
import {
dayHasSamples,
fileScopeInvariantFailuresPerDay,
@@ -1779,20 +1779,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
if (options?.systemControl && shouldScheduleAiSessionCleanup()) {
const systemControl = options.systemControl;
stopAutoUpdateWatcher?.();
stopAutoUpdateWatcher = startAutoUpdateWatcher({
stopAutoUpdateWatcher = startAutoUpdateWatcher(buildAutoUpdateDeps({
getSettings: async () => {
const globalStore = store.getGlobalSettingsStore?.();
return globalStore ? await globalStore.getSettings() : {};
},
currentVersion: cliPackageVersion,
supervised: systemControl.supervised,
requestRestart: (reason) => systemControl.requestRestart(reason),
systemControl,
log: {
info: (message, context) => runtimeLogger.info(message, context),
warn: (message, context) => runtimeLogger.warn(message, context),
error: (message, context) => runtimeLogger.error(message, context),
},
});
}));
}
/*

View File

@@ -30,10 +30,17 @@ export type UpdateCheckResult = {
error?: string;
};
export type UpdateInstallOutcome = "installed" | "no-update-available" | "check-failed" | "unsupported-install-method" | "failed";
/** `check-failed` is reserved for a route pre-install re-check; this helper never returns it. */
export type UpdateInstallResult = {
currentVersion: string;
latestVersion: string | null;
/** True exactly when outcome is `installed`, retained for older consumers. */
updated: boolean;
outcome: UpdateInstallOutcome;
/** Human-readable for every non-installed terminal outcome. */
message?: string;
error?: string;
};
@@ -150,7 +157,7 @@ function isPermissionInstallError(error: unknown): boolean {
}
/** Best-effort path of the running Fusion binary, used to tailor remediation. */
function detectRunningBinaryPath(): string | null {
export function detectRunningBinaryPath(): string | null {
const argvPath = process.argv[1];
if (typeof argvPath === "string" && argvPath.length > 0) return argvPath;
return typeof process.execPath === "string" ? process.execPath : null;
@@ -166,7 +173,7 @@ brew's own git repo, not where formulae install). So resolve the symlink and mat
real Cellar/opt install roots — checking only `/usr/local/Homebrew/` missed Intel Macs.
`/usr/local/bin` is deliberately NOT matched: it is shared with npm-global bins.
*/
function isHomebrewInstall(binaryPath: string | null): boolean {
export function isHomebrewInstall(binaryPath: string | null): boolean {
if (!binaryPath) return false;
let resolved = binaryPath;
try {
@@ -183,6 +190,30 @@ function isHomebrewInstall(binaryPath: string | null): boolean {
);
}
export function detectUnsupportedInstallMethod(input: {
sourceWorkspaceRoot?: string;
binaryPath?: string | null;
hasNpm?: boolean;
}): { reason: "source-checkout" | "homebrew" | "npm-missing"; message: string } | null {
if (input.sourceWorkspaceRoot) {
return { reason: "source-checkout", message: `This Fusion is running from a source checkout at ${input.sourceWorkspaceRoot}; a global npm install will not change it — pull and rebuild the checkout instead.` };
}
const binaryPath = input.binaryPath ?? detectRunningBinaryPath();
if (isHomebrewInstall(binaryPath)) {
return { reason: "homebrew", message: "This Fusion install is managed by Homebrew and cannot be updated with npm. Update it from a terminal with: brew upgrade fusion" };
}
if (input.hasNpm === false) {
return { reason: "npm-missing", message: "`npm` was not found on this process's PATH; update from a terminal with `npm install -g @runfusion/fusion@<version>`." };
}
return null;
}
function isNpmMissingError(error: unknown): boolean {
const installError = error as InstallError;
const message = [installError?.message, installError?.stderr, installError?.stdout].filter((part): part is string => typeof part === "string").join("\n");
return installError?.code === "ENOENT" || /command not found|not recognized as an internal/i.test(message);
}
function getPermissionRemediationMessage(binaryPath: string | null): string {
if (isHomebrewInstall(binaryPath)) {
return (
@@ -250,82 +281,45 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
export async function performUpdateInstall(
currentVersion: string,
latestVersion: string | null,
options: { exec?: ExecInstall; fusionDir?: string } = {},
options: { exec?: ExecInstall; fusionDir?: string; installMethod?: { sourceWorkspaceRoot?: string } } = {},
): Promise<UpdateInstallResult> {
const runExec = options.exec ?? execAsync;
const fusionDir = options.fusionDir ?? resolveGlobalDir();
// No resolved target → nothing safe to install. Callers guard this today;
// the guard here keeps the exec path unreachable if one ever stops.
/*
FNXC:UpdateInstall 2026-08-14-19:31:
A silent updated:false response was indistinguishable from a dead button. Failed
checks and an up-to-date re-check must never share an outcome; host-only source
checkout context is injected here so unsupported installs are refused pre-flight.
*/
const unsupported = detectUnsupportedInstallMethod({ sourceWorkspaceRoot: options.installMethod?.sourceWorkspaceRoot });
if (unsupported) return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message: unsupported.message, error: unsupported.message };
if (!latestVersion || !SAFE_VERSION_RE.test(latestVersion)) {
return {
currentVersion,
latestVersion,
updated: false,
error: `No valid update target version to install${latestVersion ? ` ('${latestVersion}')` : ""}.`,
};
const error = `No valid update target version to install${latestVersion ? ` ('${latestVersion}')` : ""}.`;
return { currentVersion, latestVersion, updated: false, outcome: "failed", message: error, error };
}
const failed = (error: string): UpdateInstallResult => ({ currentVersion, latestVersion, updated: false, outcome: "failed", message: error, error });
try {
await runExec(buildInstallCommand(latestVersion), getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
latestVersion,
updated: true,
};
return { currentVersion, latestVersion, updated: true, outcome: "installed" };
} catch (error) {
/*
FNXC:UpdateInstall 2026-07-19-09:50:
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and when exec kills a slow install, report the timeout metadata before npm's preceding deprecation warnings.
*/
if (isInstallTimeoutError(error)) {
return {
currentVersion,
latestVersion,
updated: false,
error: getInstallTimeoutMessage(latestVersion),
};
if (isNpmMissingError(error)) {
const message = detectUnsupportedInstallMethod({ hasNpm: false })!.message;
return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message, error: message };
}
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
// (from `sudo npm i -g`) yields EACCES/EPERM the non-root updater cannot
// recover from — return actionable guidance rather than raw npm stderr.
if (isPermissionInstallError(error)) {
return {
currentVersion,
latestVersion,
updated: false,
error: getPermissionRemediationMessage(detectRunningBinaryPath()),
};
}
if (!isBinCollisionInstallError(error)) {
return {
currentVersion,
latestVersion,
updated: false,
error: getInstallErrorMessage(error),
};
}
if (isInstallTimeoutError(error)) return failed(getInstallTimeoutMessage(latestVersion));
if (isPermissionInstallError(error)) return failed(getPermissionRemediationMessage(detectRunningBinaryPath()));
if (!isBinCollisionInstallError(error)) return failed(getInstallErrorMessage(error));
try {
await runExec(buildInstallCommand(latestVersion, true), getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
latestVersion,
updated: true,
};
return { currentVersion, latestVersion, updated: true, outcome: "installed" };
} catch (forceError) {
return {
currentVersion,
latestVersion,
updated: false,
error: isInstallTimeoutError(forceError)
? getInstallTimeoutMessage(latestVersion, true)
: getInstallErrorMessage(forceError),
};
if (isNpmMissingError(forceError)) {
const message = detectUnsupportedInstallMethod({ hasNpm: false })!.message;
return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message, error: message };
}
return failed(isInstallTimeoutError(forceError) ? getInstallTimeoutMessage(latestVersion, true) : getInstallErrorMessage(forceError));
}
}
}