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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 22:52:53 -07:00
parent 2dbfe3d312
commit 99b80ad748
19 changed files with 760 additions and 41 deletions

View File

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

View File

@@ -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`. | | `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. | | `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". | | `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). | | `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. | | `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. | | `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. |

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest"; 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"; import { FUSION_NON_RETRYABLE_EXIT_CODE } from "@fusion/core";
/* /*
@@ -27,6 +27,48 @@ describe("shouldSuperviseDashboard", () => {
expect(shouldSuperviseDashboard(["dashboard"], { FUSION_RESTART_SUPERVISED: "1" }, [])).toBe(false); 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)", () => { 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=9230"])).toBe(false);
expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect-brk"])).toBe(false); expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect-brk"])).toBe(false);

View File

@@ -1228,7 +1228,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
let restartScheduled = false; let restartScheduled = false;
let requestSelfRestart: ((reason: string) => boolean) | null = null; let requestSelfRestart: ((reason: string) => boolean) | null = null;
const systemControlForServer = { 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), requestRestart: (reason: string) => (requestSelfRestart ? requestSelfRestart(reason) : false),
sourceWorkspaceRoot: resolveFusionSourceWorkspaceRoot(), sourceWorkspaceRoot: resolveFusionSourceWorkspaceRoot(),
}; };
@@ -3508,6 +3510,37 @@ export function resolveSupervisorRespawnCommand(): { command: string; args: stri
return { command: process.execPath, args: [...process.execArgv, entryPoint] }; 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: FNXC:SystemPanel 2026-07-12-14:05:
Supervision decision for `fn dashboard` (and bare `fn`, which defaults to the 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: and gets crash recovery. Skipped when:
- --no-supervise is passed (explicit opt-out; also the escape hatch for - --no-supervise is passed (explicit opt-out; also the escape hatch for
debugging the child directly), debugging the child directly),
- FUSION_RESTART_SUPERVISED=1 (a supervising parent already exists — the - a supervising parent is genuinely present (the supervisor's own child, or
supervisor's own child, or scripts/dev-with-memory.mjs under `pnpm dev` — scripts/dev-with-memory.mjs under `pnpm dev` — never nest supervisors). A
so never nest supervisors), 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 - an inspector flag is active (the debugger must attach to the real app
process, and a respawned child would fight over the inspector port), process, and a respawned child would fight over the inspector port),
- no respawn command can be resolved. - no respawn command can be resolved.
@@ -3527,9 +3563,10 @@ export function shouldSuperviseDashboard(
args: readonly string[], args: readonly string[],
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
execArgv: readonly string[] = process.execArgv, execArgv: readonly string[] = process.execArgv,
ppid: number = process.ppid,
): boolean { ): boolean {
if (args.includes("--no-supervise")) return false; 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; if (execArgv.some((arg) => arg.startsWith("--inspect"))) return false;
return resolveSupervisorRespawnCommand() !== null; return resolveSupervisorRespawnCommand() !== null;
} }
@@ -3721,7 +3758,13 @@ supervisor's own exit/SIGTERM handlers.
function spawnAttached(command: string, args: string[]): { child: ChildProcess; waitExit: Promise<AttachedChildExit> } { function spawnAttached(command: string, args: string[]): { child: ChildProcess; waitExit: Promise<AttachedChildExit> } {
const child = spawn(command, args, { const child = spawn(command, args, {
stdio: "inherit", 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<AttachedChildExit>((resolve) => { const waitExit = new Promise<AttachedChildExit>((resolve) => {
child.on("close", (code, signal) => resolve({ code, signal })); child.on("close", (code, signal) => resolve({ code, signal }));

View File

@@ -208,6 +208,12 @@ export const DEFAULT_GLOBAL_SETTINGS = {
// FNXC:UpdateChannels 2026-07-19-12:30: release track for update surfaces; // FNXC:UpdateChannels 2026-07-19-12:30: release track for update surfaces;
// "stable" follows npm dist-tag `latest`, "beta" follows max(latest, beta). // "stable" follows npm dist-tag `latest`, "beta" follows max(latest, beta).
updateChannel: "stable", 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, autoReloadOnVersionChange: true,
githubTrackingDefaultRepo: undefined, githubTrackingDefaultRepo: undefined,
reportRoadmapDedupeEnabled: undefined, reportRoadmapDedupeEnabled: undefined,

View File

@@ -600,6 +600,20 @@ export interface GlobalSettings {
* is the explicit downgrade escape hatch). Default: `stable`. * is the explicit downgrade escape hatch). Default: `stable`.
*/ */
updateChannel?: UpdateChannel; 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 /** When true (default), the dashboard automatically reloads when a new build
* version is detected via /version.json polling or service worker activation. * version is detected via /version.json polling or service worker activation.
* Set to false to suppress automatic reloads — the user must manually * Set to false to suppress automatic reloads — the user must manually

View File

@@ -1953,7 +1953,16 @@ export function SettingsModal({
const handleCheckForUpdates = useCallback(async () => { const handleCheckForUpdates = useCallback(async () => {
setUpdateCheckLoading(true); setUpdateCheckLoading(true);
setUpdateInstallResult(null); 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); setRestartLoading(false);
setRestartScheduled(false); setRestartScheduled(false);
setRestartError(null); setRestartError(null);
@@ -1997,6 +2006,17 @@ export function SettingsModal({
if (result.updated) { if (result.updated) {
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success"); 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) { } catch (error) {
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed"); const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
@@ -2012,13 +2032,17 @@ export function SettingsModal({
} }
}, [addToast, appVersion, projectId, t, updateCheckResult]); }, [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(() => { useEffect(() => {
if (!updateCheckResult?.updateAvailable && updateInstallResult?.updated !== true) {
return;
}
let cancelled = false; let cancelled = false;
setRestartSupported(undefined);
void fetchSystemInfo() void fetchSystemInfo()
.then((info) => { .then((info) => {
@@ -2032,16 +2056,23 @@ export function SettingsModal({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [updateCheckResult?.updateAvailable, updateInstallResult?.updated]); }, []);
/* /*
FNXC:SettingsUpdate 2026-07-16-00:00: FNXC:SettingsUpdate 2026-07-16-00:00:
After a successful in-app update, the Settings footer must offer the same supervised 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 one-click restart as SystemControlsArea.
the control disabled with manual-restart guidance unless restartSupported is true.
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 () => { const handleRestart = useCallback(async () => {
if (restartLoading || restartSupported !== true) return; if (restartLoading) return;
setRestartLoading(true); setRestartLoading(true);
setRestartError(null); setRestartError(null);
@@ -2057,7 +2088,7 @@ export function SettingsModal({
} finally { } finally {
setRestartLoading(false); setRestartLoading(false);
} }
}, [restartLoading, restartSupported, t]); }, [restartLoading, t]);
const renderUpdateCheckResultContent = useCallback(() => { const renderUpdateCheckResultContent = useCallback(() => {
if (!updateCheckResult) { if (!updateCheckResult) {
@@ -2103,7 +2134,7 @@ export function SettingsModal({
onClick={() => { onClick={() => {
void handleRestart(); void handleRestart();
}} }}
disabled={restartSupported !== true || restartLoading} disabled={restartLoading}
> >
{restartLoading ? ( {restartLoading ? (
<> <>
@@ -2118,7 +2149,14 @@ export function SettingsModal({
)} )}
</button> </button>
)} )}
{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 && (
<span className="settings-update-install-status" aria-live="polite"> <span className="settings-update-install-status" aria-live="polite">
{t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")} {t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
</span> </span>

View File

@@ -59,10 +59,14 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
/* /*
FNXC:UpdateBanner 2026-07-16-00:00: FNXC:UpdateBanner 2026-07-16-00:00:
Issue #1799 requires a successful in-app update to offer the supervised restart hook in-place. 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 () => { const handleRestart = async () => {
if (restartLoading || restartSupported !== true) return; if (restartLoading) return;
setRestartLoading(true); setRestartLoading(true);
setRestartError(null); setRestartError(null);
@@ -82,7 +86,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
const installSucceeded = installResult?.updated === true; const installSucceeded = installResult?.updated === true;
const installError = installResult?.error; 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 ( return (
<div className="update-available-banner" role="status" aria-live="polite"> <div className="update-available-banner" role="status" aria-live="polite">
@@ -126,7 +131,7 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
onClick={() => { onClick={() => {
void handleRestart(); void handleRestart();
}} }}
disabled={restartUnavailable || restartLoading} disabled={restartLoading}
> >
{restartLoading ? ( {restartLoading ? (
<> <>

View File

@@ -320,30 +320,43 @@ describe("SettingsModal", () => {
expect(await screen.findByText(/Restarting… Your connection will close shortly/)).toBeInTheDocument(); 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 }); mockFetchSystemInfo.mockResolvedValue({ supervised: false, restartSupported: false });
mockRequestSystemRestart.mockRejectedValue(new Error("Restart is not available: no supervising parent."));
const restartButton = await renderUpdatedSettings(); const restartButton = await renderUpdatedSettings();
expect(restartButton).toBeDisabled(); await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument());
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(() => {})); mockFetchSystemInfo.mockReturnValue(new Promise(() => {}));
const restartButton = await renderUpdatedSettings(); 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")); mockFetchSystemInfo.mockRejectedValue(new Error("unavailable"));
const restartButton = await renderUpdatedSettings(); const restartButton = await renderUpdatedSettings();
await waitFor(() => expect(restartButton).toBeDisabled()); await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument());
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); expect(restartButton).toBeEnabled();
}); });
it("disables the restart button and shows a spinner while scheduling", async () => { 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(); 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 () => { it("keeps the retry path and hides restart when update installation fails", async () => {
mockInstallUpdate.mockResolvedValue({ mockInstallUpdate.mockResolvedValue({
currentVersion: "1.2.3", currentVersion: "1.2.3",

View File

@@ -113,30 +113,44 @@ describe("UpdateAvailableBanner", () => {
expect(await screen.findByText("Restarting… Your connection will close shortly.")).toBeInTheDocument(); 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 }); mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported: false });
mockRequestSystemRestart.mockRejectedValueOnce(new Error("Restart is not available: no supervising parent."));
renderBanner(); renderBanner();
await completeInstall(); 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(); 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(() => {})); mockFetchSystemInfo.mockReturnValueOnce(new Promise(() => {}));
renderBanner(); renderBanner();
await completeInstall(); 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")); mockFetchSystemInfo.mockRejectedValueOnce(new Error("network unavailable"));
renderBanner(); renderBanner();
await completeInstall(); await completeInstall();
await waitFor(() => expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled()); await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument());
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 () => { 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).toBeInTheDocument();
expect(actions).toContainElement(restartButton); expect(actions).toContainElement(restartButton);
expect(restartButton).toBeInTheDocument(); expect(restartButton).toBeInTheDocument();
expect(restartButton).toHaveProperty("disabled", !restartSupported); expect(restartButton).toHaveProperty("disabled", false);
if (!restartSupported) expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument(); if (!restartSupported) expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth }); Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });

View File

@@ -156,6 +156,8 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"updateCheckEnabled", "updateCheckEnabled",
"updateCheckFrequency", "updateCheckFrequency",
"updateChannel", "updateChannel",
// FNXC:AutoUpdate 2026-07-25-10:05: global-general owns save/reset for the unattended-update opt-in.
"autoUpdateAndRestart",
"autoReloadOnVersionChange", "autoReloadOnVersionChange",
]), ]),
/* /*

View File

@@ -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. ", " 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"], 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", sectionId: "global-general",
key: "autoReloadOnVersionChange", key: "autoReloadOnVersionChange",

View File

@@ -147,6 +147,23 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp
updateChannel: v as "stable" | "beta", 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.
*/}
<SettingsToggleRow
descriptor={{
key: "autoUpdateAndRestart",
label: t("settings.globalGeneral.autoUpdateAndRestart", " Auto-update and restart "),
help: t("settings.globalGeneral.autoUpdateAndRestartHelp", " 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. "),
scope: "global",
}}
value={form.autoUpdateAndRestart === true}
onChange={(v) => setForm((f) => ({ ...f, autoUpdateAndRestart: v === true }))}
/>
<SettingsToggleRow <SettingsToggleRow
descriptor={{ descriptor={{
key: "autoReloadOnVersionChange", key: "autoReloadOnVersionChange",

View File

@@ -87,6 +87,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
updateCheckFrequency: "globalGeneral.controlsHowOftenTheDashboardReFetchesThe", updateCheckFrequency: "globalGeneral.controlsHowOftenTheDashboardReFetchesThe",
autoReloadOnVersionChange: "globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen", autoReloadOnVersionChange: "globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen",
updateChannel: "globalGeneral.releaseChannelHelp", updateChannel: "globalGeneral.releaseChannelHelp",
autoUpdateAndRestart: "globalGeneral.autoUpdateAndRestartHelp",
// AppearanceSection // AppearanceSection
openTasksInRightSidebar: "appearance.openTasksInRightSidebarHelp", openTasksInRightSidebar: "appearance.openTasksInRightSidebarHelp",
openMobileTasksInPopup: "appearance.openMobileTasksInPopupHelp", openMobileTasksInPopup: "appearance.openMobileTasksInPopupHelp",

View File

@@ -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> = {}): AutoUpdateDeps & {
requestRestart: ReturnType<typeof vi.fn>;
installUpdate: ReturnType<typeof vi.fn>;
checkForUpdate: ReturnType<typeof vi.fn>;
} {
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();
});
});

View File

@@ -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<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
export interface AutoUpdateDeps {
/** Reads the current global settings; failures are treated as "leave it off". */
getSettings: () => Promise<AutoUpdateSettings>;
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<AutoUpdateOutcome> {
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<typeof setInterval> | 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);
}

View File

@@ -73,6 +73,7 @@ import type { CliRelaunchRegistry } from "./cli-session-transport.js";
import { validateRemoteAuthToken } from "./remote-auth.js"; import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js"; import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js";
import { performUpdateCheck } from "./update-check.js"; import { performUpdateCheck } from "./update-check.js";
import { startAutoUpdateWatcher } from "./auto-update.js";
import { import {
dayHasSamples, dayHasSamples,
fileScopeInvariantFailuresPerDay, fileScopeInvariantFailuresPerDay,
@@ -155,6 +156,13 @@ const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
let aiSessionCleanupIntervalHandle: ReturnType<typeof setInterval> | undefined; let aiSessionCleanupIntervalHandle: ReturnType<typeof setInterval> | 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 { function clearAiSessionCleanupInterval(): void {
if (!aiSessionCleanupIntervalHandle) { if (!aiSessionCleanupIntervalHandle) {
return; 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: * FNXC:PostgresHealth 2026-06-24-16:10:
* The /api/health endpoint is async because PostgreSQL health checks * The /api/health endpoint is async because PostgreSQL health checks

View File

@@ -6067,6 +6067,8 @@
"skipConfirmationDialogsHint": " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled", "skipConfirmationDialogsHint": " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled",
"releaseChannel": "Release channel", "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.", "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)", "channelStable": "Stable (recommended)",
"channelBeta": "Beta — early builds from main" "channelBeta": "Beta — early builds from main"
}, },

View File

@@ -79,7 +79,13 @@ function runApp(extraArgs) {
loader: LOADER, loader: LOADER,
entry: ENTRY, entry: ENTRY,
args: extraArgs, 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) => { tsx.on("close", (c) => {
if (c === RESTART_EXIT_CODE) { if (c === RESTART_EXIT_CODE) {
console.log("[fusion:dev] restart requested — restarting…"); console.log("[fusion:dev] restart requested — restarting…");