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`. |
|
| `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. |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
]),
|
]),
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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…");
|
||||||
|
|||||||
Reference in New Issue
Block a user