From 99b80ad748ee8bf2271e31c93334cc0d4a5784ea Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 22:52:53 -0700 Subject: [PATCH] feat(dashboard): add opt-in auto-update and harden restart supervision Add the `autoUpdateAndRestart` global setting (default off, Settings -> General next to Release channel). When enabled, the dashboard host installs available updates on the selected channel by itself and requests the supervised in-place restart. Supervised hosts only: without a parent to respawn, installing would leave a running process whose code no longer matches its own install. Fix two ways the restart affordance could silently do nothing: - The supervisor now stamps FUSION_SUPERVISOR_PID and supervision is only counted when that pid is the real parent. FUSION_RESTART_SUPERVISED is inherited by every process Fusion spawns, so `fn dashboard` launched from an agent terminal skipped its own supervisor while still advertising restart support -- a restart request then killed it for good. - Settings and the update banner probe /system/info on mount and treat capability as advisory: the button always issues the request and shows the server's actual refusal instead of sitting disabled after a failed probe. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/auto-update-and-restart.md | 7 + docs/settings-reference.md | 1 + .../__tests__/dashboard-supervise.test.ts | 44 +++- packages/cli/src/commands/dashboard.ts | 55 ++++- packages/core/src/settings-schema.ts | 6 + packages/core/src/types/settings-scope.ts | 14 ++ .../app/components/SettingsModal.tsx | 64 ++++- .../app/components/UpdateAvailableBanner.tsx | 13 +- .../__tests__/SettingsModal.general.test.tsx | 68 +++++- .../__tests__/UpdateAvailableBanner.test.tsx | 30 ++- .../app/components/settings/save-split.ts | 2 + .../sections/GlobalGeneralSection.search.ts | 10 + .../sections/GlobalGeneralSection.tsx | 17 ++ .../settings-default-descriptions.test.tsx | 1 + .../src/__tests__/auto-update.test.ts | 226 ++++++++++++++++++ packages/dashboard/src/auto-update.ts | 196 +++++++++++++++ packages/dashboard/src/server.ts | 37 +++ packages/i18n/locales/en/app.json | 2 + scripts/dev-with-memory.mjs | 8 +- 19 files changed, 760 insertions(+), 41 deletions(-) create mode 100644 .changeset/auto-update-and-restart.md create mode 100644 packages/dashboard/src/__tests__/auto-update.test.ts create mode 100644 packages/dashboard/src/auto-update.ts diff --git a/.changeset/auto-update-and-restart.md b/.changeset/auto-update-and-restart.md new file mode 100644 index 0000000000..c2b3615cb3 --- /dev/null +++ b/.changeset/auto-update-and-restart.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add opt-in auto-update and make the post-update Restart button report why it was refused. +category: feature +dev: New global setting `autoUpdateAndRestart` (default false, Settings → General next to Release channel) drives `startAutoUpdateWatcher` in the dashboard server — channel-aware check + `performUpdateInstall` + `systemControl.requestRestart`, supervised hosts only. The supervisor now stamps `FUSION_SUPERVISOR_PID` and `hasLiveSupervisingParent()` verifies it against `process.ppid`, so an inherited `FUSION_RESTART_SUPERVISED` (agent terminals, dev servers) no longer suppresses self-supervision or fakes restart support. Settings and the update banner probe `/system/info` on mount and treat capability as advisory: the restart button always issues the request and surfaces the server's refusal instead of sitting disabled. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 3a5fb418df..5217781dd8 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -116,6 +116,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, and re-run that refresh after saving an `opencode`/`opencode-go` API key in Dashboard Settings, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. | | `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. | | `updateChannel` | `"stable" \| "beta"` | `"stable"` | Release track for every update surface (CLI `fn update`, dashboard update check, desktop auto-updater). `stable` follows the npm `latest` dist-tag; `beta` follows the semver-max of `latest` and `beta`, so beta users also receive each promoted stable release. Switching beta → stable never downgrades — the install stays on its beta until the next stable overtakes it (`fn update --channel stable --force` downgrades explicitly). Dashboard location: **Settings → General → Release channel**. See `RELEASING.md` → "Release tracks". | +| `autoUpdateAndRestart` | `boolean` | `false` | When enabled, the dashboard host installs available updates on the selected `updateChannel` by itself (same install path as the Settings "Update now" button) and then requests the supervised in-place restart so the new version is actually running. Checked ~1 minute after boot and every 6 hours; honors `updateCheckEnabled`. Only acts on a **supervised** host (`fn dashboard` supervises by default) — without a supervising parent the install is skipped and logged, because replacing the running program's files with nothing to respawn it leaves a process whose code no longer matches its install. Dashboard location: **Settings → General → Auto-update and restart**. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). | | `gitlabEnabled` | `boolean` | `undefined` (effective `true`) | Global fallback enable switch for outbound GitLab integrations. Undefined preserves existing behavior; explicit `false` disables GitLab API fetch/import/comment/close/reconcile/refresh operations while leaving saved URL/token settings intact. Projects can override this key. Dashboard location: **Settings → Global General → GitLab Configuration** disclosure. | | `gitlabInstanceUrl` | `string` | `undefined` (effective `https://gitlab.com`) | Global fallback GitLab web instance URL. Blank/unset defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Projects can override this key. | diff --git a/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts index 82857a9d88..eb2df34d38 100644 --- a/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { classifyDashboardFatalExit, resolveSupervisorRespawnCommand, shouldSuperviseDashboard } from "../dashboard.js"; +import { classifyDashboardFatalExit, hasLiveSupervisingParent, resolveSupervisorRespawnCommand, shouldSuperviseDashboard } from "../dashboard.js"; import { FUSION_NON_RETRYABLE_EXIT_CODE } from "@fusion/core"; /* @@ -27,6 +27,48 @@ describe("shouldSuperviseDashboard", () => { expect(shouldSuperviseDashboard(["dashboard"], { FUSION_RESTART_SUPERVISED: "1" }, [])).toBe(false); }); + /* + FNXC:SystemPanel 2026-07-25-10:05: + FUSION_RESTART_SUPERVISED is inherited by every process the dashboard spawns + (agent terminals, dev servers). Running `fn dashboard` from one of those must + still start a real supervisor — otherwise the new dashboard advertises restart + support it does not have and a restart request kills it for good. The pid stamp + distinguishes a real parent from an inherited copy. + */ + it("never nests under the real supervising parent (pid matches)", () => { + expect( + shouldSuperviseDashboard(["dashboard"], { FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: "4242" }, [], 4242), + ).toBe(false); + }); + + it("supervises itself when the supervised flag was merely inherited from a non-parent", () => { + expect( + shouldSuperviseDashboard(["dashboard"], { FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: "4242" }, [], 99), + ).toBe(true); + }); +}); + +describe("hasLiveSupervisingParent", () => { + it("requires the supervised flag", () => { + expect(hasLiveSupervisingParent({}, 1)).toBe(false); + }); + + it("accepts a parent that predates the pid stamp (legacy dev wrapper)", () => { + expect(hasLiveSupervisingParent({ FUSION_RESTART_SUPERVISED: "1" }, 1)).toBe(true); + }); + + it("accepts the stamped supervisor when it is our actual parent", () => { + expect(hasLiveSupervisingParent({ FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: "77" }, 77)).toBe(true); + }); + + it("rejects a stamped pid that is not our parent (leaked env / dead supervisor)", () => { + expect(hasLiveSupervisingParent({ FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: "77" }, 1)).toBe(false); + }); + + it("rejects an unparseable pid stamp", () => { + expect(hasLiveSupervisingParent({ FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: "nope" }, 1)).toBe(false); + }); + it("is disabled when an inspector is attached (child would fight over the port)", () => { expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect=9230"])).toBe(false); expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect-brk"])).toBe(false); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index ea7cf32e89..b2a1ad42c8 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1228,7 +1228,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: let restartScheduled = false; let requestSelfRestart: ((reason: string) => boolean) | null = null; const systemControlForServer = { - supervised: process.env.FUSION_RESTART_SUPERVISED === "1", + // FNXC:SystemPanel 2026-07-25-10:05: proof of a LIVE supervising parent, not + // just an inherited env flag — see hasLiveSupervisingParent. + supervised: hasLiveSupervisingParent(), requestRestart: (reason: string) => (requestSelfRestart ? requestSelfRestart(reason) : false), sourceWorkspaceRoot: resolveFusionSourceWorkspaceRoot(), }; @@ -3508,6 +3510,37 @@ export function resolveSupervisorRespawnCommand(): { command: string; args: stri return { command: process.execPath, args: [...process.execArgv, entryPoint] }; } +/* +FNXC:SystemPanel 2026-07-25-10:05: +Is a supervising parent ACTUALLY there, right now? + +FUSION_RESTART_SUPERVISED=1 alone is not proof. It is a plain environment +variable, so it is inherited by every process the dashboard spawns — agent +terminals, dev servers, shells. Running `fn dashboard` from inside one of those +made the new dashboard believe it was supervised: it advertised +restartSupported=true, and a restart request then exited the process with +FUSION_RESTART_EXIT_CODE with nobody listening for it. The dashboard just +disappeared — which is what "the restart button does nothing" looks like from a +browser tab that never comes back. + +The supervisor now also stamps its own pid (FUSION_SUPERVISOR_PID). Supervision +counts only when that pid is our real parent: a leaked copy of the variable +names a process that is not our parent (or a dead one — we get reparented, so +ppid stops matching), and we correctly report unsupervised. A parent that +predates the stamp (older scripts/dev-with-memory.mjs) sets no pid, so the flag +alone still counts — no behavior change for it. +*/ +export function hasLiveSupervisingParent( + env: NodeJS.ProcessEnv = process.env, + ppid: number = process.ppid, +): boolean { + if (env.FUSION_RESTART_SUPERVISED !== "1") return false; + const declaredPid = env.FUSION_SUPERVISOR_PID; + if (!declaredPid) return true; + const parsed = Number.parseInt(declaredPid, 10); + return Number.isFinite(parsed) && parsed === ppid; +} + /* FNXC:SystemPanel 2026-07-12-14:05: Supervision decision for `fn dashboard` (and bare `fn`, which defaults to the @@ -3516,9 +3549,12 @@ dashboard). Supervision is now the DEFAULT so every install shape — bare `fn`, and gets crash recovery. Skipped when: - --no-supervise is passed (explicit opt-out; also the escape hatch for debugging the child directly), - - FUSION_RESTART_SUPERVISED=1 (a supervising parent already exists — the - supervisor's own child, or scripts/dev-with-memory.mjs under `pnpm dev` — - so never nest supervisors), + - a supervising parent is genuinely present (the supervisor's own child, or + scripts/dev-with-memory.mjs under `pnpm dev` — never nest supervisors). A + merely INHERITED FUSION_RESTART_SUPERVISED no longer counts; see + hasLiveSupervisingParent above. This is what makes `fn dashboard` launched + from a Fusion-spawned terminal supervise itself instead of silently losing + restart support. - an inspector flag is active (the debugger must attach to the real app process, and a respawned child would fight over the inspector port), - no respawn command can be resolved. @@ -3527,9 +3563,10 @@ export function shouldSuperviseDashboard( args: readonly string[], env: NodeJS.ProcessEnv = process.env, execArgv: readonly string[] = process.execArgv, + ppid: number = process.ppid, ): boolean { if (args.includes("--no-supervise")) return false; - if (env.FUSION_RESTART_SUPERVISED === "1") return false; + if (hasLiveSupervisingParent(env, ppid)) return false; if (execArgv.some((arg) => arg.startsWith("--inspect"))) return false; return resolveSupervisorRespawnCommand() !== null; } @@ -3721,7 +3758,13 @@ supervisor's own exit/SIGTERM handlers. function spawnAttached(command: string, args: string[]): { child: ChildProcess; waitExit: Promise } { const child = spawn(command, args, { stdio: "inherit", - env: { ...process.env, FUSION_RESTART_SUPERVISED: "1" }, + /* + FNXC:SystemPanel 2026-07-25-10:05: + FUSION_SUPERVISOR_PID lets the child verify this supervisor is its actual + parent. Without it, any grandchild that inherits FUSION_RESTART_SUPERVISED + (agent terminals, dev servers) would claim restart support it does not have. + */ + env: { ...process.env, FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: String(process.pid) }, }); const waitExit = new Promise((resolve) => { child.on("close", (code, signal) => resolve({ code, signal })); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index f33b873230..a2b96653e2 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -208,6 +208,12 @@ export const DEFAULT_GLOBAL_SETTINGS = { // FNXC:UpdateChannels 2026-07-19-12:30: release track for update surfaces; // "stable" follows npm dist-tag `latest`, "beta" follows max(latest, beta). updateChannel: "stable", + /* + FNXC:AutoUpdate 2026-07-25-10:05: + Unattended update install + supervised restart. Default OFF — an operator must + opt in before Fusion replaces its own binary and bounces the process under them. + */ + autoUpdateAndRestart: false, autoReloadOnVersionChange: true, githubTrackingDefaultRepo: undefined, reportRoadmapDedupeEnabled: undefined, diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index 54bb3c537c..368c8420f6 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -600,6 +600,20 @@ export interface GlobalSettings { * is the explicit downgrade escape hatch). Default: `stable`. */ updateChannel?: UpdateChannel; + /** + * FNXC:AutoUpdate 2026-07-25-10:05: + * When true, the dashboard host installs available updates on its own + * (channel-aware, same install path as the Settings "Update now" button) and + * then requests the supervised in-place restart so the new version is actually + * running. Default false: unattended self-replacement + process bounce must be + * an explicit operator choice. + * + * Only honored on a supervised host (`fn dashboard` — supervision is the + * default). Without a supervising parent the install would leave a running + * process whose on-disk code no longer matches, so the watcher skips the + * install entirely and logs why instead. + */ + autoUpdateAndRestart?: boolean; /** When true (default), the dashboard automatically reloads when a new build * version is detected via /version.json polling or service worker activation. * Set to false to suppress automatic reloads — the user must manually diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ca09101351..7c6a6ee200 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1953,7 +1953,16 @@ export function SettingsModal({ const handleCheckForUpdates = useCallback(async () => { setUpdateCheckLoading(true); setUpdateInstallResult(null); - setRestartSupported(undefined); + /* + FNXC:SettingsUpdate 2026-07-25-10:05: + Do NOT clear restartSupported here. It is a property of the HOST (is there a + supervising parent?), not of this update check, and clearing it stranded the + post-update "Restart Fusion" button: the capability effect was keyed on + updateAvailable, so a second "Check now" that returned the same + updateAvailable=true left restartSupported permanently `undefined` and the + button disabled with "Needs a supervising parent" on a perfectly supervised + host. The probe below owns this state for the modal's lifetime. + */ setRestartLoading(false); setRestartScheduled(false); setRestartError(null); @@ -1997,6 +2006,17 @@ export function SettingsModal({ if (result.updated) { addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success"); + /* + FNXC:SettingsUpdate 2026-07-25-10:05: + Re-probe capability right before the restart button appears so a transient + /system/info failure at mount (which fails closed to `false`) cannot leave a + supervised host permanently unable to restart from Settings. + */ + void fetchSystemInfo() + .then((info) => setRestartSupported(info.restartSupported)) + .catch(() => { + // Keep whatever the mount probe resolved; the guidance text covers it. + }); } } catch (error) { const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed"); @@ -2012,13 +2032,17 @@ export function SettingsModal({ } }, [addToast, appVersion, projectId, t, updateCheckResult]); + /* + FNXC:SettingsUpdate 2026-07-25-10:05: + Probe host restart capability once when Settings mounts — same unconditional + shape UpdateAvailableBanner and the Command Center System panel use. It used to + run only after an update became available, which made the state order-dependent + and left the restart button dead in the re-check case described above. Fetching + on mount means the capability is already resolved by the time an install + finishes, so the button is enabled the moment it appears. + */ useEffect(() => { - if (!updateCheckResult?.updateAvailable && updateInstallResult?.updated !== true) { - return; - } - let cancelled = false; - setRestartSupported(undefined); void fetchSystemInfo() .then((info) => { @@ -2032,16 +2056,23 @@ export function SettingsModal({ return () => { cancelled = true; }; - }, [updateCheckResult?.updateAvailable, updateInstallResult?.updated]); + }, []); /* FNXC:SettingsUpdate 2026-07-16-00:00: After a successful in-app update, the Settings footer must offer the same supervised - one-click restart as SystemControlsArea. The FN-8134-deferred Settings surface keeps - the control disabled with manual-restart guidance unless restartSupported is true. + one-click restart as SystemControlsArea. + + FNXC:SettingsUpdate 2026-07-25-10:05: + The control must never silently do nothing. It used to be hard-disabled on + `restartSupported !== true`, so any host whose capability probe answered false — + including a probe that merely failed, or a stale answer — left the operator with a + dead button and no way to learn why ("the restart button does nothing"). Now the + click always reaches the server and the server's own refusal is shown inline; the + supervising-parent line stays as advisory guidance rather than a hard block. */ const handleRestart = useCallback(async () => { - if (restartLoading || restartSupported !== true) return; + if (restartLoading) return; setRestartLoading(true); setRestartError(null); @@ -2057,7 +2088,7 @@ export function SettingsModal({ } finally { setRestartLoading(false); } - }, [restartLoading, restartSupported, t]); + }, [restartLoading, t]); const renderUpdateCheckResultContent = useCallback(() => { if (!updateCheckResult) { @@ -2103,7 +2134,7 @@ export function SettingsModal({ onClick={() => { void handleRestart(); }} - disabled={restartSupported !== true || restartLoading} + disabled={restartLoading} > {restartLoading ? ( <> @@ -2118,7 +2149,14 @@ export function SettingsModal({ )} )} - {restartSupported !== true && ( + {/* + FNXC:SettingsUpdate 2026-07-25-10:05: + Advisory, not a block. The probe says this host reported no supervising + parent, so restarting will likely be refused — but the operator can still + press the button and read the server's actual reason instead of facing a + dead control. + */} + {restartSupported === false && ( {t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")} diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.tsx b/packages/dashboard/app/components/UpdateAvailableBanner.tsx index 23dfdb0a9a..6b46c8a1b0 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.tsx +++ b/packages/dashboard/app/components/UpdateAvailableBanner.tsx @@ -59,10 +59,14 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss /* FNXC:UpdateBanner 2026-07-16-00:00: Issue #1799 requires a successful in-app update to offer the supervised restart hook in-place. - Hosts without restart support keep the control visible but disabled with manual-restart guidance. + + FNXC:UpdateBanner 2026-07-25-10:05: + Capability is advisory, never a hard block — same contract as the Settings footer. + A failed or stale /system/info probe must not turn this into a button that silently + does nothing; let the request reach the server and show its refusal reason instead. */ const handleRestart = async () => { - if (restartLoading || restartSupported !== true) return; + if (restartLoading) return; setRestartLoading(true); setRestartError(null); @@ -82,7 +86,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss const installSucceeded = installResult?.updated === true; const installError = installResult?.error; - const restartUnavailable = restartSupported !== true; + // Advisory guidance only — shown when the host explicitly reported no supervising parent. + const restartUnavailable = restartSupported === false; return (
@@ -126,7 +131,7 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss onClick={() => { void handleRestart(); }} - disabled={restartUnavailable || restartLoading} + disabled={restartLoading} > {restartLoading ? ( <> diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 7d234e0ff2..81ed975db3 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -320,30 +320,43 @@ describe("SettingsModal", () => { expect(await screen.findByText(/Restarting… Your connection will close shortly/)).toBeInTheDocument(); }); - it("keeps the restart button disabled with manual guidance when unsupported", async () => { + /* + FNXC:SettingsUpdate 2026-07-25-10:05: + Capability is advisory, not a hard block. An unsupported host shows the manual + guidance but the button still reaches the server, so the operator gets the real + refusal instead of a control that silently does nothing when clicked. + */ + it("shows manual guidance but still surfaces the server refusal when unsupported", async () => { mockFetchSystemInfo.mockResolvedValue({ supervised: false, restartSupported: false }); + mockRequestSystemRestart.mockRejectedValue(new Error("Restart is not available: no supervising parent.")); const restartButton = await renderUpdatedSettings(); - expect(restartButton).toBeDisabled(); - expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument()); + expect(restartButton).toBeEnabled(); + + await settingsModalUser.click(restartButton); + + expect(mockRequestSystemRestart).toHaveBeenCalledWith("settings-update"); + expect(await screen.findByText(/Restart is not available: no supervising parent\./)).toBeInTheDocument(); }); - it("keeps the restart button disabled while system information is loading", async () => { + it("still allows a restart attempt while system information is loading", async () => { mockFetchSystemInfo.mockReturnValue(new Promise(() => {})); const restartButton = await renderUpdatedSettings(); - expect(restartButton).toBeDisabled(); + expect(restartButton).toBeEnabled(); + expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument(); }); - it("fails closed with manual guidance when system information cannot load", async () => { + it("shows manual guidance when system information cannot load", async () => { mockFetchSystemInfo.mockRejectedValue(new Error("unavailable")); const restartButton = await renderUpdatedSettings(); - await waitFor(() => expect(restartButton).toBeDisabled()); - expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument()); + expect(restartButton).toBeEnabled(); }); it("disables the restart button and shows a spinner while scheduling", async () => { @@ -376,6 +389,45 @@ describe("SettingsModal", () => { expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled(); }); + /* + FNXC:SettingsUpdate 2026-07-25-10:05: + Regression: restart capability is a property of the HOST, not of a particular + update check. Clicking "Check for updates" repeatedly (same updateAvailable + result each time) used to strand restartSupported at `undefined` — the probe + was keyed on updateAvailable flipping — so the post-install "Restart Fusion" + button was disabled with "Needs a supervising parent" on a supervised host. + The invariant asserted here is: on a supervised host the restart button is + enabled after an install regardless of how many checks preceded it. + */ + it("keeps the restart button enabled after repeated update checks", async () => { + mockCheckForUpdates.mockResolvedValue(availableUpdate); + renderModal(); + await waitForSettingsModalReady(); + + const checkButton = screen.getByRole("button", { name: "Check for updates" }); + await settingsModalUser.click(checkButton); + await screen.findByRole("button", { name: "Update now" }); + await settingsModalUser.click(checkButton); + await screen.findByRole("button", { name: "Update now" }); + + await settingsModalUser.click(screen.getByRole("button", { name: "Update now" })); + + const restartButton = await screen.findByRole("button", { name: "Restart Fusion" }); + await waitFor(() => expect(restartButton).toBeEnabled()); + expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument(); + }); + + 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. + mockFetchSystemInfo.mockRejectedValueOnce(new Error("unavailable")); + + const restartButton = await renderUpdatedSettings(); + + await waitFor(() => expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument()); + expect(restartButton).toBeEnabled(); + }); + it("keeps the retry path and hides restart when update installation fails", async () => { mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", diff --git a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx index 3afe2e0d80..506de16979 100644 --- a/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx @@ -113,30 +113,44 @@ describe("UpdateAvailableBanner", () => { expect(await screen.findByText("Restarting… Your connection will close shortly.")).toBeInTheDocument(); }); - it("renders the restart button disabled with manual guidance when unsupported", async () => { + /* + FNXC:UpdateBanner 2026-07-25-10:05: + Restart capability is advisory, not a hard block: the button always reaches the + server so an operator sees the real refusal instead of a control that silently + does nothing (a failed or stale /system/info probe used to disable it outright). + */ + it("shows manual guidance but still attempts the restart when unsupported", async () => { mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported: false }); + mockRequestSystemRestart.mockRejectedValueOnce(new Error("Restart is not available: no supervising parent.")); renderBanner(); await completeInstall(); - expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled(); + const restartButton = screen.getByRole("button", { name: "Restart Fusion" }); + expect(restartButton).toBeEnabled(); expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); + + fireEvent.click(restartButton); + + await waitFor(() => expect(mockRequestSystemRestart).toHaveBeenCalledWith("update-banner")); + expect(await screen.findByText(/Restart is not available: no supervising parent\./)).toBeInTheDocument(); }); - it("keeps restart disabled while system info is loading", async () => { + it("allows a restart attempt while system info is still loading", async () => { mockFetchSystemInfo.mockReturnValueOnce(new Promise(() => {})); renderBanner(); await completeInstall(); - expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled(); + expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument(); }); - it("fails closed with manual guidance when system info cannot be loaded", async () => { + it("shows manual guidance when system info cannot be loaded", async () => { mockFetchSystemInfo.mockRejectedValueOnce(new Error("network unavailable")); renderBanner(); await completeInstall(); - await waitFor(() => expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled()); - expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled(); }); it("shows a disabled spinning restart action while a restart request is in flight", async () => { @@ -206,7 +220,7 @@ describe("UpdateAvailableBanner", () => { expect(actions).toBeInTheDocument(); expect(actions).toContainElement(restartButton); expect(restartButton).toBeInTheDocument(); - expect(restartButton).toHaveProperty("disabled", !restartSupported); + expect(restartButton).toHaveProperty("disabled", false); if (!restartSupported) expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth }); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 5d570dab2e..3e1c7f1d77 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -156,6 +156,8 @@ export const GLOBAL_SECTION_KEYS: Record> = { "updateCheckEnabled", "updateCheckFrequency", "updateChannel", + // FNXC:AutoUpdate 2026-07-25-10:05: global-general owns save/reset for the unattended-update opt-in. + "autoUpdateAndRestart", "autoReloadOnVersionChange", ]), /* diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts index e56da7fbcf..8a3525e8e4 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts @@ -74,6 +74,16 @@ export const globalGeneralSearchEntries: SettingsSearchEntry[] = [ " Stable follows official releases. Beta follows pre-releases cut from main (versions like 0.73.0-beta.2) and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades; you stay on the installed beta until the next stable release passes it. Default: stable. ", keywords: ["beta", "channel", "release track", "prerelease", "early access"], }, + { + sectionId: "global-general", + key: "autoUpdateAndRestart", + labelKey: "settings.globalGeneral.autoUpdateAndRestart", + labelFallback: " Auto-update and restart ", + helpKey: "settings.globalGeneral.autoUpdateAndRestartHelp", + helpFallback: + " When enabled, Fusion installs available updates on the selected release channel by itself and then restarts to apply them — the same install + restart the \"Update now\" button performs, without asking. Requires a supervising parent (the default for `fn dashboard`); hosts started with --no-supervise skip the install. Default: disabled. ", + keywords: ["auto update", "automatic update", "self update", "unattended", "restart"], + }, { sectionId: "global-general", key: "autoReloadOnVersionChange", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 98ecea0fa2..975e273730 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -147,6 +147,23 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp updateChannel: v as "stable" | "beta", }))} /> + {/* + FNXC:AutoUpdate 2026-07-25-10:05: + Operator opt-in for unattended updates, placed directly under the release channel it + follows. Default OFF: Fusion must not replace its own install and bounce the process + without being told to. Only effective on a supervised host (`fn dashboard`), which is + the default launch path; the server-side watcher logs and skips otherwise. + */} + setForm((f) => ({ ...f, autoUpdateAndRestart: v === true }))} + /> = { updateCheckFrequency: "globalGeneral.controlsHowOftenTheDashboardReFetchesThe", autoReloadOnVersionChange: "globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen", updateChannel: "globalGeneral.releaseChannelHelp", + autoUpdateAndRestart: "globalGeneral.autoUpdateAndRestartHelp", // AppearanceSection openTasksInRightSidebar: "appearance.openTasksInRightSidebarHelp", openMobileTasksInPopup: "appearance.openMobileTasksInPopupHelp", diff --git a/packages/dashboard/src/__tests__/auto-update.test.ts b/packages/dashboard/src/__tests__/auto-update.test.ts new file mode 100644 index 0000000000..4c1b573f59 --- /dev/null +++ b/packages/dashboard/src/__tests__/auto-update.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js"; +import type { AutoUpdateDeps } from "../auto-update.js"; + +/* +FNXC:AutoUpdate 2026-07-25-10:05: +Contract for the unattended update watcher: opt-in only, supervised hosts only, +install before restart, never restart on a failed install. +*/ + +function makeDeps(overrides: Partial = {}): AutoUpdateDeps & { + requestRestart: ReturnType; + installUpdate: ReturnType; + checkForUpdate: ReturnType; +} { + const checkForUpdate = vi.fn().mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updateAvailable: true, + lastChecked: 0, + }); + const installUpdate = vi.fn().mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: true, + }); + return { + getSettings: async () => ({ autoUpdateAndRestart: true }), + currentVersion: "1.0.0", + supervised: true, + requestRestart: vi.fn().mockReturnValue(true), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + fusionDir: "/tmp/fusion-auto-update-test", + checkForUpdate, + installUpdate, + ...overrides, + } as never; +} + +describe("runAutoUpdateCycle", () => { + it("installs and restarts when enabled on a supervised host", async () => { + const deps = makeDeps(); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("restarting"); + + expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir }); + expect(deps.requestRestart).toHaveBeenCalledWith("auto-update"); + }); + + it("does nothing when the setting is off (default)", async () => { + const deps = makeDeps({ getSettings: async () => ({}) }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("disabled"); + + expect(deps.checkForUpdate).not.toHaveBeenCalled(); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + + it("does nothing when update checks are disabled", async () => { + const deps = makeDeps({ + getSettings: async () => ({ autoUpdateAndRestart: true, updateCheckEnabled: false }), + }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("checks-disabled"); + expect(deps.checkForUpdate).not.toHaveBeenCalled(); + }); + + it("never installs on an unsupervised host", async () => { + const deps = makeDeps({ supervised: false }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("unsupervised"); + + expect(deps.checkForUpdate).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + + it("treats unreadable settings as opted out", async () => { + const deps = makeDeps({ + getSettings: async () => { + throw new Error("settings unavailable"); + }, + }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("disabled"); + expect(deps.installUpdate).not.toHaveBeenCalled(); + }); + + it("passes the configured release channel to the check", async () => { + const deps = makeDeps({ + getSettings: async () => ({ autoUpdateAndRestart: true, updateChannel: "beta" }), + }); + + await runAutoUpdateCycle(deps); + + expect(deps.checkForUpdate).toHaveBeenCalledWith("/tmp/fusion-auto-update-test", "1.0.0", { + force: true, + channel: "beta", + }); + }); + + it("does not install or restart when already up to date", async () => { + const deps = makeDeps(); + deps.checkForUpdate.mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: "1.0.0", + updateAvailable: false, + lastChecked: 0, + }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("up-to-date"); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + + it("never restarts when the install fails", async () => { + const deps = makeDeps(); + deps.installUpdate.mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: false, + error: "npm exploded", + }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("install-failed"); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + + it("reports a rejected install without throwing", async () => { + const deps = makeDeps(); + deps.installUpdate.mockRejectedValue(new Error("EACCES")); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("install-failed"); + expect(deps.requestRestart).not.toHaveBeenCalled(); + }); + + it("reports a failed check without installing", async () => { + const deps = makeDeps(); + deps.checkForUpdate.mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: null, + updateAvailable: false, + lastChecked: 0, + error: "registry unreachable", + }); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("check-failed"); + expect(deps.installUpdate).not.toHaveBeenCalled(); + }); + + it("surfaces an installed update whose restart could not be scheduled", async () => { + const deps = makeDeps(); + deps.requestRestart.mockReturnValue(false); + + await expect(runAutoUpdateCycle(deps)).resolves.toBe("restart-unavailable"); + expect(deps.log.warn).toHaveBeenCalled(); + }); +}); + +describe("startAutoUpdateWatcher", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs a first cycle after the initial delay and stops once a restart is scheduled", async () => { + const deps = makeDeps(); + + const stop = startAutoUpdateWatcher(deps, { initialDelayMs: 1_000, intervalMs: 10_000 }); + + expect(deps.checkForUpdate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(deps.checkForUpdate).toHaveBeenCalledTimes(1); + + // The process is shutting down for the restart — no further cycles. + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.checkForUpdate).toHaveBeenCalledTimes(1); + stop(); + }); + + it("keeps polling while no update is available", async () => { + const deps = makeDeps(); + deps.checkForUpdate.mockResolvedValue({ + currentVersion: "1.0.0", + latestVersion: "1.0.0", + updateAvailable: false, + lastChecked: 0, + }); + + const stop = startAutoUpdateWatcher(deps, { initialDelayMs: 1_000, intervalMs: 10_000 }); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(10_000); + expect(deps.checkForUpdate).toHaveBeenCalledTimes(3); + + stop(); + await vi.advanceTimersByTimeAsync(10_000); + expect(deps.checkForUpdate).toHaveBeenCalledTimes(3); + }); + + it("never overlaps cycles when an install outlives the interval", async () => { + const deps = makeDeps(); + let releaseInstall: (() => void) | undefined; + deps.installUpdate.mockImplementation( + () => + new Promise((resolve) => { + releaseInstall = () => resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true }); + }), + ); + + const stop = startAutoUpdateWatcher(deps, { initialDelayMs: 1_000, intervalMs: 5_000 }); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(20_000); + + expect(deps.checkForUpdate).toHaveBeenCalledTimes(1); + expect(deps.installUpdate).toHaveBeenCalledTimes(1); + + releaseInstall?.(); + stop(); + }); +}); diff --git a/packages/dashboard/src/auto-update.ts b/packages/dashboard/src/auto-update.ts new file mode 100644 index 0000000000..2f766f70cb --- /dev/null +++ b/packages/dashboard/src/auto-update.ts @@ -0,0 +1,196 @@ +import { resolveGlobalDir } from "@fusion/core"; +import type { UpdateChannel } from "@fusion/core"; +import { performUpdateCheck, performUpdateInstall } from "./update-check.js"; +import type { UpdateCheckResult, UpdateInstallResult } from "./update-check.js"; + +/* +FNXC:AutoUpdate 2026-07-25-10:05: +Unattended update watcher behind the global `autoUpdateAndRestart` setting +(default OFF, Settings → General → Updates, next to the release channel). + +Requirement: "add option to auto update and restart on update". When enabled the +host installs an available update by itself — the same channel-aware check and +`npm install -g` path the Settings "Update now" button uses — and then requests +the supervised in-place restart so the newly installed version is the one that +is actually running. + +Deliberate constraints: + - Server-side, not browser-driven: an unattended update must not depend on a + dashboard tab being open. + - Supervised hosts only. Installing replaces the running program's files on + disk; without a supervising parent to respawn, the still-running process + would keep lazily importing modules from a tree that no longer matches its + loaded code. No supervisor -> skip the install and say so once. + - Honors `updateCheckEnabled`: an operator who turned update checks off must + not get network calls (or installs) from this watcher. + - Never restarts without a successful install, and never installs twice for + the same version once a restart is already scheduled. +*/ + +/** Initial delay before the first cycle — keeps boot free of an npm round-trip. */ +const DEFAULT_INITIAL_DELAY_MS = 60_000; +/** Steady-state cadence. Independent of `updateCheckFrequency`, which is the cache TTL for *display* surfaces. */ +const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000; + +type AutoUpdateSettings = { + autoUpdateAndRestart?: boolean; + updateCheckEnabled?: boolean; + updateChannel?: UpdateChannel; +}; + +export type AutoUpdateOutcome = + | "disabled" + | "checks-disabled" + | "unsupervised" + | "up-to-date" + | "check-failed" + | "install-failed" + | "restart-unavailable" + | "restarting"; + +export interface AutoUpdateLogger { + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +export interface AutoUpdateDeps { + /** Reads the current global settings; failures are treated as "leave it off". */ + getSettings: () => Promise; + currentVersion: string; + supervised: boolean; + /** Same contract as the System panel: false when no supervising parent will respawn. */ + requestRestart: (reason: string) => boolean; + log: AutoUpdateLogger; + fusionDir?: string; + /** Test seams. */ + checkForUpdate?: typeof performUpdateCheck; + installUpdate?: typeof performUpdateInstall; +} + +/** + * Run one auto-update cycle. Exported for tests and for callers that want a + * single deterministic pass instead of the timer loop. + */ +export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise { + let settings: AutoUpdateSettings; + try { + settings = await deps.getSettings(); + } catch { + // Unreadable settings must never be read as "opted in". + return "disabled"; + } + + if (settings.autoUpdateAndRestart !== true) return "disabled"; + if (settings.updateCheckEnabled === false) return "checks-disabled"; + + if (!deps.supervised) { + deps.log.warn("Auto-update skipped: no supervising parent", { + message: "autoUpdateAndRestart is on but this host cannot restart itself. Start via `fn dashboard` (supervision is the default) to enable it.", + }); + return "unsupervised"; + } + + const fusionDir = deps.fusionDir ?? resolveGlobalDir(); + const check = deps.checkForUpdate ?? performUpdateCheck; + const install = deps.installUpdate ?? performUpdateInstall; + + let result: UpdateCheckResult; + try { + result = await check(fusionDir, deps.currentVersion, { + force: true, + channel: settings.updateChannel, + }); + } catch (error) { + deps.log.warn("Auto-update check failed", { message: errorMessage(error) }); + return "check-failed"; + } + + if (result.error) { + deps.log.warn("Auto-update check failed", { message: result.error }); + return "check-failed"; + } + if (!result.updateAvailable || !result.latestVersion) return "up-to-date"; + + deps.log.info("Auto-update installing", { + currentVersion: result.currentVersion, + latestVersion: result.latestVersion, + channel: result.channel ?? settings.updateChannel ?? "stable", + }); + + let installed: UpdateInstallResult; + try { + installed = await install(result.currentVersion, result.latestVersion, { fusionDir }); + } 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" }); + return "install-failed"; + } + + const scheduled = 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.`, + }); + return "restart-unavailable"; + } + + deps.log.info("Auto-update installed — restarting", { latestVersion: installed.latestVersion }); + return "restarting"; +} + +/** + * Start the periodic auto-update loop. Returns a stop function. Timers are + * unref'd so the watcher never keeps the process alive on its own. + */ +export function startAutoUpdateWatcher( + deps: AutoUpdateDeps, + options: { initialDelayMs?: number; intervalMs?: number } = {}, +): () => void { + const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS; + const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + + let stopped = false; + let running = false; + let interval: ReturnType | undefined; + + const tick = async () => { + // A cycle can outlive its interval (npm installs are slow) — never overlap: + // two concurrent `npm install -g` runs would fight over the same prefix. + if (stopped || running) return; + running = true; + try { + const outcome = await runAutoUpdateCycle(deps); + // A restart is already shutting the process down; stop scheduling work. + if (outcome === "restarting") stop(); + } catch (error) { + deps.log.error("Auto-update cycle failed", { message: errorMessage(error) }); + } finally { + running = false; + } + }; + + const initial = setTimeout(() => { + void tick(); + if (stopped) return; + interval = setInterval(() => void tick(), intervalMs); + interval.unref?.(); + }, initialDelayMs); + initial.unref?.(); + + function stop(): void { + stopped = true; + clearTimeout(initial); + if (interval) clearInterval(interval); + } + + return stop; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index cf0acc9c6c..161999a8d0 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -73,6 +73,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 { dayHasSamples, fileScopeInvariantFailuresPerDay, @@ -155,6 +156,13 @@ const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; let aiSessionCleanupIntervalHandle: ReturnType | undefined; +/* +FNXC:AutoUpdate 2026-07-25-10:05: +Module-scoped so a second createServer() in the same process (tests, embedded +desktop server) replaces the previous watcher instead of stacking npm installs. +*/ +let stopAutoUpdateWatcher: (() => void) | undefined; + function clearAiSessionCleanupInterval(): void { if (!aiSessionCleanupIntervalHandle) { return; @@ -1709,6 +1717,35 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } } + /* + FNXC:AutoUpdate 2026-07-25-10:05: + Optional unattended update install + supervised restart (global setting + `autoUpdateAndRestart`, default OFF). Started only when the host CLI wired + systemControl — that injection is what makes an in-place restart possible at + all, and the watcher itself re-reads the setting every cycle, so toggling it in + Settings takes effect without a restart. Skipped under NODE_ENV=test for the + same reason as the AI-session sweep: unit servers must not schedule timers or + reach npm. + */ + if (options?.systemControl && shouldScheduleAiSessionCleanup()) { + const systemControl = options.systemControl; + stopAutoUpdateWatcher?.(); + stopAutoUpdateWatcher = startAutoUpdateWatcher({ + getSettings: async () => { + const globalStore = store.getGlobalSettingsStore?.(); + return globalStore ? await globalStore.getSettings() : {}; + }, + currentVersion: cliPackageVersion, + supervised: systemControl.supervised, + requestRestart: (reason) => systemControl.requestRestart(reason), + log: { + info: (message, context) => runtimeLogger.info(message, context), + warn: (message, context) => runtimeLogger.warn(message, context), + error: (message, context) => runtimeLogger.error(message, context), + }, + }); + } + /* * FNXC:PostgresHealth 2026-06-24-16:10: * The /api/health endpoint is async because PostgreSQL health checks diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 2d9ecce093..b663a1157f 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6067,6 +6067,8 @@ "skipConfirmationDialogsHint": " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled", "releaseChannel": "Release channel", "releaseChannelHelp": "Stable follows official releases. Beta follows pre-releases cut from main and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades. Default: stable.", + "autoUpdateAndRestart": " Auto-update and restart ", + "autoUpdateAndRestartHelp": "When enabled, Fusion installs available updates on the selected release channel by itself and then restarts to apply them. Requires a supervising parent (the default for `fn dashboard`); hosts started with --no-supervise skip the install. Default: disabled.", "channelStable": "Stable (recommended)", "channelBeta": "Beta — early builds from main" }, diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index c2f57b41c1..6ed6831bcd 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -79,7 +79,13 @@ function runApp(extraArgs) { loader: LOADER, entry: ENTRY, args: extraArgs, - }), { stdio: "inherit", env: { ...process.env, FUSION_RESTART_SUPERVISED: "1" } }); + }), { + stdio: "inherit", + // FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the + // flag so the child can tell a real supervising parent from an inherited + // copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts). + env: { ...process.env, FUSION_RESTART_SUPERVISED: "1", FUSION_SUPERVISOR_PID: String(process.pid) }, + }); tsx.on("close", (c) => { if (c === RESTART_EXIT_CODE) { console.log("[fusion:dev] restart requested — restarting…");