diff --git a/.changeset/fn-9042-update-now-outcomes.md b/.changeset/fn-9042-update-now-outcomes.md new file mode 100644 index 0000000000..78f3d08c63 --- /dev/null +++ b/.changeset/fn-9042-update-now-outcomes.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d5bd31b943..04a6103e2c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c8cc291f62..21acd8befa 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. --- diff --git a/packages/dashboard/app/api/settings/settings.ts b/packages/dashboard/app/api/settings/settings.ts index bb2e556ad5..c4b4d21591 100644 --- a/packages/dashboard/app/api/settings/settings.ts +++ b/packages/dashboard/app/api/settings/settings.ts @@ -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; } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 9abcdd4e2f..f25d33f358 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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({ )} )} - {installError && ( - - {t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })} + {installMessage && !installSucceeded && ( + + {installIsError ? t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage} )} diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.tsx b/packages/dashboard/app/components/UpdateAvailableBanner.tsx index 6b46c8a1b0..6196bc9e6c 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.tsx +++ b/packages/dashboard/app/components/UpdateAvailableBanner.tsx @@ -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 )} )} - {installError && ( - - {t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })} + {installMessage && !installSucceeded && ( + + {installIsError ? t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage} )} diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 7165d5945e..31ecfff413 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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. diff --git a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx index 506de16979..c44ae6f896 100644 --- a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx @@ -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], diff --git a/packages/dashboard/src/__tests__/auto-update.test.ts b/packages/dashboard/src/__tests__/auto-update.test.ts index 4c1b573f59..7da14289cf 100644 --- a/packages/dashboard/src/__tests__/auto-update.test.ts +++ b/packages/dashboard/src/__tests__/auto-update.test.ts @@ -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" }); + }); + }); diff --git a/packages/dashboard/src/__tests__/update-check.test.ts b/packages/dashboard/src/__tests__/update-check.test.ts index efa4ad560d..703ee20b47 100644 --- a/packages/dashboard/src/__tests__/update-check.test.ts +++ b/packages/dashboard/src/__tests__/update-check.test.ts @@ -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"); + }); }); diff --git a/packages/dashboard/src/auto-update.ts b/packages/dashboard/src/auto-update.ts index 2f766f70cb..87f021739f 100644 --- a/packages/dashboard/src/auto-update.ts +++ b/packages/dashboard/src/auto-update.ts @@ -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; + 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 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(); + 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) { + 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(); + }); +}); diff --git a/packages/dashboard/src/routes/register-update-check-routes.ts b/packages/dashboard/src/routes/register-update-check-routes.ts index 51ace54134..dddec453ba 100644 --- a/packages/dashboard/src/routes/register-update-check-routes.ts +++ b/packages/dashboard/src/routes/register-update-check-routes.ts @@ -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"); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 0b8a36759b..122d5c0b96 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -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), }, - }); + })); } /* diff --git a/packages/dashboard/src/update-check.ts b/packages/dashboard/src/update-check.ts index 56ec5e7f4f..2e479f40db 100644 --- a/packages/dashboard/src/update-check.ts +++ b/packages/dashboard/src/update-check.ts @@ -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@`." }; + } + 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 { export async function performUpdateInstall( currentVersion: string, latestVersion: string | null, - options: { exec?: ExecInstall; fusionDir?: string } = {}, + options: { exec?: ExecInstall; fusionDir?: string; installMethod?: { sourceWorkspaceRoot?: string } } = {}, ): Promise { 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)); } } }