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:
7
.changeset/auto-update-and-restart.md
Normal file
7
.changeset/auto-update-and-restart.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AttachedChildExit> } {
|
||||
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<AttachedChildExit>((resolve) => {
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</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">
|
||||
{t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<div className="update-available-banner" role="status" aria-live="polite">
|
||||
@@ -126,7 +131,7 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
|
||||
onClick={() => {
|
||||
void handleRestart();
|
||||
}}
|
||||
disabled={restartUnavailable || restartLoading}
|
||||
disabled={restartLoading}
|
||||
>
|
||||
{restartLoading ? (
|
||||
<>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -156,6 +156,8 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
"updateCheckEnabled",
|
||||
"updateCheckFrequency",
|
||||
"updateChannel",
|
||||
// FNXC:AutoUpdate 2026-07-25-10:05: global-general owns save/reset for the unattended-update opt-in.
|
||||
"autoUpdateAndRestart",
|
||||
"autoReloadOnVersionChange",
|
||||
]),
|
||||
/*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/}
|
||||
<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
|
||||
descriptor={{
|
||||
key: "autoReloadOnVersionChange",
|
||||
|
||||
@@ -87,6 +87,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
updateCheckFrequency: "globalGeneral.controlsHowOftenTheDashboardReFetchesThe",
|
||||
autoReloadOnVersionChange: "globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen",
|
||||
updateChannel: "globalGeneral.releaseChannelHelp",
|
||||
autoUpdateAndRestart: "globalGeneral.autoUpdateAndRestartHelp",
|
||||
// AppearanceSection
|
||||
openTasksInRightSidebar: "appearance.openTasksInRightSidebarHelp",
|
||||
openMobileTasksInPopup: "appearance.openMobileTasksInPopupHelp",
|
||||
|
||||
226
packages/dashboard/src/__tests__/auto-update.test.ts
Normal file
226
packages/dashboard/src/__tests__/auto-update.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
196
packages/dashboard/src/auto-update.ts
Normal file
196
packages/dashboard/src/auto-update.ts
Normal 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);
|
||||
}
|
||||
@@ -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<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 {
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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…");
|
||||
|
||||
Reference in New Issue
Block a user