diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67ed1ebd7c..5406b06f73 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.16", @@ -32,6 +32,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.11.2", + "semver": "^7.8.5", "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" }, @@ -51,6 +52,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^6.0.2", "app-builder-lib": "^26.15.3", "electron": "^33.0.0", @@ -181,6 +183,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -225,6 +237,16 @@ "yallist": "^3.0.2" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -676,19 +698,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron-forge/cli/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron-forge/core": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.11.2.tgz", @@ -893,19 +902,6 @@ "node": ">=10" } }, - "node_modules/@electron-forge/core-utils/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron-forge/core-utils/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -1122,19 +1118,6 @@ "node": ">=10" } }, - "node_modules/@electron-forge/core/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron-forge/core/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -1609,19 +1592,6 @@ "node": ">=10" } }, - "node_modules/@electron-forge/shared-types/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron-forge/shared-types/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -1698,19 +1668,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron-forge/template-base/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron-forge/template-base/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -2087,6 +2044,16 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", @@ -2263,19 +2230,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/@electron/node-gyp/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/node-gyp/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -2595,19 +2549,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/@electron/packager/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/rebuild": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", @@ -3398,19 +3339,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@npmcli/move-file": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", @@ -6500,6 +6428,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8940,19 +8875,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/electron-installer-common/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/electron-installer-common/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -10444,20 +10366,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -12404,19 +12312,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -12427,19 +12322,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", - "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12521,19 +12403,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -14077,13 +13946,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { diff --git a/frontend/package.json b/frontend/package.json index a70a05a573..1ef76fc08f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^6.0.2", "app-builder-lib": "^26.15.3", "electron": "^33.0.0", @@ -77,6 +78,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.11.2", + "semver": "^7.8.5", "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" }, diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts index 1c8527b153..da5f1c0e49 100644 --- a/frontend/src/main/auto-updater.ts +++ b/frontend/src/main/auto-updater.ts @@ -1,6 +1,7 @@ import { autoUpdater } from "electron-updater"; import { app, BrowserWindow, dialog } from "electron"; import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { readUpdateSettings, @@ -9,6 +10,7 @@ import { type UpdateChannel, type UpdateStatus, } from "./update-settings"; +import { evaluateEscalation } from "./escalation-evaluator"; // configureFeed sets the update channel on electron-updater. The repo/owner // are loaded automatically from app-update.yml (written by forge.config.ts's @@ -28,6 +30,14 @@ function configureFeed(channel: UpdateChannel): void { let lastStatus: UpdateStatus = { state: "idle" }; let eventsWired = false; +// Staged-update tracking for the escalation evaluator: set on update-downloaded, +// re-evaluated every 30 minutes while the update sits uninstalled. stateDir is +// captured from whichever entry point wired the events (both receive it). +let stagedVersion: string | undefined; +let stagedAtMs: number | undefined; +let escalationTimer: ReturnType | undefined; +let escalationStateDir: string | undefined; + // broadcast pushes the latest update status to every renderer window so the // Global Settings Updates section can reflect check/download progress live. function broadcast(status: UpdateStatus): void { @@ -37,6 +47,97 @@ function broadcast(status: UpdateStatus): void { } } +// --- Read-only release-feed helpers (packaged app only; every failure is silent). +// These regex-parse flat keys out of electron-builder yml files on purpose: no +// yaml dependency, and a parse miss just means "no info", never an error state +// (see issue #2270 for why this path must not broadcast errors). + +/** Owner/repo from the bundled app-update.yml; undefined in dev or on any failure. */ +async function readAppUpdateYml(): Promise<{ owner: string; repo: string } | undefined> { + if (!app.isPackaged) return undefined; + try { + const yml = await readFile(path.join(process.resourcesPath, "app-update.yml"), "utf8"); + const owner = /^owner:\s*(.+)$/m.exec(yml)?.[1]?.trim(); + const repo = /^repo:\s*(.+)$/m.exec(yml)?.[1]?.trim(); + return owner && repo ? { owner, repo } : undefined; + } catch { + return undefined; + } +} + +/** Platform suffix matching the feed.mjs naming convention. */ +function platformSuffix(): string { + if (process.platform === "darwin") return "-mac"; + if (process.platform === "linux") return "-linux"; + return ""; +} + +/** Latest stable version via GitHub's /releases/latest redirect; undefined on any failure. */ +async function fetchLatestStableVersion(owner: string, repo: string): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest/download/latest${platformSuffix()}.yml`; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) return undefined; + return /^version:\s*(.+)$/m.exec(await res.text())?.[1]?.trim() || undefined; + } catch { + return undefined; + } +} + +/** important flag on the staged nightly's release yml; false when absent, 404, or any failure. */ +async function fetchNightlyImportant(owner: string, repo: string, version: string): Promise { + const url = `https://github.com/${owner}/${repo}/releases/download/v${version}/nightly${platformSuffix()}.yml`; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) return false; + return /^important:\s*true\s*$/m.test(await res.text()); + } catch { + return false; + } +} + +// runEscalationCheck re-reads settings and feeds, then rebroadcasts the +// downloaded status with a fresh escalated flag. It only runs while the last +// status is still "downloaded" (clears the timer otherwise) and never +// broadcasts an error state: every failure degrades to escalated staying put. +async function runEscalationCheck(): Promise { + if (stagedVersion === undefined || stagedAtMs === undefined || escalationStateDir === undefined) return; + if (lastStatus.state !== "downloaded") { + stopEscalationTimer(); + return; + } + try { + const settings = await readUpdateSettings(escalationStateDir); + let important = false; + let latestStableVersion: string | undefined; + const coords = await readAppUpdateYml(); + if (coords && settings.channel === "nightly") { + [latestStableVersion, important] = await Promise.all([ + fetchLatestStableVersion(coords.owner, coords.repo), + fetchNightlyImportant(coords.owner, coords.repo, stagedVersion), + ]); + } + const escalated = evaluateEscalation({ + channel: settings.channel, + stagedAt: stagedAtMs, + now: Date.now(), + important, + runningVersion: app.getVersion(), + latestStableVersion, + }); + broadcast({ state: "downloaded", version: stagedVersion, stagedAt: stagedAtMs, escalated }); + } catch (err) { + console.debug("escalation check skipped:", err); + } +} + +function stopEscalationTimer(): void { + if (escalationTimer !== undefined) { + clearInterval(escalationTimer); + escalationTimer = undefined; + } +} + // wireUpdaterEvents registers electron-updater listeners once and forwards each // to the renderer as an UpdateStatus. Idempotent: safe to call on every entry // point (launch auto-check and manual check). @@ -49,7 +150,18 @@ function wireUpdaterEvents(): void { autoUpdater.on("download-progress", (p) => broadcast({ state: "downloading", percent: Math.max(0, Math.min(100, Math.round(p?.percent ?? 0))) }), ); - autoUpdater.on("update-downloaded", (info) => broadcast({ state: "downloaded", version: info?.version })); + autoUpdater.on("update-downloaded", (info) => { + stagedVersion = info?.version; + stagedAtMs = Date.now(); + broadcast({ state: "downloaded", version: stagedVersion, stagedAt: stagedAtMs, escalated: false }); + // Evaluate now (nightly can escalate immediately), then every 30 minutes + // while the update sits uninstalled. unref so the timer never holds the + // process open on quit. + void runEscalationCheck(); + stopEscalationTimer(); + escalationTimer = setInterval(() => void runEscalationCheck(), 30 * 60 * 1000); + escalationTimer.unref?.(); + }); autoUpdater.on("error", (err) => { // Never crash on update failure (offline, unsigned macOS, etc.). broadcast({ state: "error", message: err?.message ?? String(err) }); @@ -67,6 +179,7 @@ export async function startAutoUpdates(stateDir: string): Promise { const settings = await readUpdateSettings(stateDir); if (!settings.enabled) return; + escalationStateDir = stateDir; wireUpdaterEvents(); configureFeed(settings.channel); autoUpdater.autoDownload = true; @@ -85,6 +198,7 @@ export async function startAutoUpdates(stateDir: string): Promise { // reports progress via the broadcast status. Updates only work in the packaged, // signed app; in dev electron-updater has no feed, so surface that plainly. export async function checkForUpdatesNow(stateDir: string): Promise { + escalationStateDir = stateDir; wireUpdaterEvents(); if (!app.isPackaged) { broadcast({ state: "unsupported", message: "Updates are only available in the installed app." }); diff --git a/frontend/src/main/escalation-evaluator.test.ts b/frontend/src/main/escalation-evaluator.test.ts new file mode 100644 index 0000000000..599187333d --- /dev/null +++ b/frontend/src/main/escalation-evaluator.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { evaluateEscalation } from "./escalation-evaluator"; + +const H48 = 48 * 60 * 60 * 1000; +const now = Date.now(); + +describe("evaluateEscalation - latest channel", () => { + it("not escalated under 48h", () => { + expect( + evaluateEscalation({ + channel: "latest", + stagedAt: now - H48 + 1000, + now, + important: false, + runningVersion: "0.10.4", + latestStableVersion: "0.10.5", + }), + ).toBe(false); + }); + + it("escalated at exactly 48h", () => { + expect( + evaluateEscalation({ + channel: "latest", + stagedAt: now - H48, + now, + important: false, + runningVersion: "0.10.4", + latestStableVersion: "0.10.5", + }), + ).toBe(true); + }); + + it("escalated over 48h even without stable version info", () => { + expect( + evaluateEscalation({ + channel: "latest", + stagedAt: now - H48 - 1, + now, + important: false, + runningVersion: "0.10.4", + latestStableVersion: undefined, + }), + ).toBe(true); + }); +}); + +describe("evaluateEscalation - nightly channel", () => { + it("escalated when the important flag is set", () => { + expect( + evaluateEscalation({ + channel: "nightly", + stagedAt: now, + now, + important: true, + runningVersion: "0.10.4-nightly.202607031330", + latestStableVersion: undefined, + }), + ).toBe(true); + }); + + it("escalated when running nightly is behind stable of the same base", () => { + // 0.10.4-nightly.x is a pre-release of 0.10.4, so it is behind stable 0.10.4. + expect( + evaluateEscalation({ + channel: "nightly", + stagedAt: now, + now, + important: false, + runningVersion: "0.10.4-nightly.202607031330", + latestStableVersion: "0.10.4", + }), + ).toBe(true); + }); + + it("not escalated when nightly is ahead of stable", () => { + expect( + evaluateEscalation({ + channel: "nightly", + stagedAt: now, + now, + important: false, + runningVersion: "0.10.4-nightly.202607031330", + latestStableVersion: "0.10.3", + }), + ).toBe(false); + }); + + it("not escalated when stable version info is missing", () => { + expect( + evaluateEscalation({ + channel: "nightly", + stagedAt: now - H48 * 10, + now, + important: false, + runningVersion: "0.10.4-nightly.202607031330", + latestStableVersion: undefined, + }), + ).toBe(false); + }); + + it("not escalated on unparseable version strings", () => { + expect( + evaluateEscalation({ + channel: "nightly", + stagedAt: now, + now, + important: false, + runningVersion: "not-a-version", + latestStableVersion: "0.10.4", + }), + ).toBe(false); + }); +}); diff --git a/frontend/src/main/escalation-evaluator.ts b/frontend/src/main/escalation-evaluator.ts new file mode 100644 index 0000000000..dc99b88db6 --- /dev/null +++ b/frontend/src/main/escalation-evaluator.ts @@ -0,0 +1,44 @@ +import semver from "semver"; +import type { UpdateChannel } from "./update-settings"; + +const H48 = 48 * 60 * 60 * 1000; + +/** + * Pure escalation decision. Returns true when the user should be nudged harder + * to restart and install a downloaded update. + * + * latest channel: escalated after 48 hours of sitting staged. + * nightly channel: escalated when the feed marks the staged build important, OR + * when the running version is behind the latest stable release (time to jump + * to stable). The semver term is skipped when latestStableVersion could not + * be fetched. + */ +export function evaluateEscalation(opts: { + channel: UpdateChannel; + stagedAt: number; + now: number; + important: boolean; + runningVersion: string; + latestStableVersion: string | undefined; +}): boolean { + const { channel, stagedAt, now, important, runningVersion, latestStableVersion } = opts; + + if (channel === "latest") { + return now - stagedAt >= H48; + } + + // nightly channel + if (important) return true; + if (latestStableVersion !== undefined) { + // semver.lt handles pre-release comparisons correctly: + // "0.10.4-nightly.202607031330" < "0.10.4" (pre-release < release), + // but not < "0.10.3" (major.minor.patch is higher). + try { + return semver.lt(runningVersion, latestStableVersion); + } catch { + // Unparseable version strings: do not escalate. + return false; + } + } + return false; +} diff --git a/frontend/src/main/update-settings.ts b/frontend/src/main/update-settings.ts index 3d47205754..edd8f32b26 100644 --- a/frontend/src/main/update-settings.ts +++ b/frontend/src/main/update-settings.ts @@ -19,6 +19,11 @@ export interface UpdateStatus { version?: string; percent?: number; message?: string; + // Present only when state === "downloaded". + // stagedAt: epoch ms when the update finished downloading. + // escalated: true when per-channel rules say the user should be nudged harder. + stagedAt?: number; + escalated?: boolean; } /** File holding the user's auto-update preferences under the ~/.ao state dir. */ diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 7a95264ed2..e49473af2b 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -8,12 +8,14 @@ import { MoreVertical, Pencil, Plus, + RefreshCw, Search, Settings, Sun, Trash2, } from "lucide-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { UpdateStatus } from "../../main/update-settings"; import { attentionZone, newestActiveOrchestrator, @@ -281,6 +283,7 @@ export function Sidebar({ 12px top, 0 bottom, content-hugging button). The icon rail keeps the icon-only settings action plus expand toggle (off macOS). */} +
@@ -342,6 +345,7 @@ export function Sidebar({
+ @@ -714,6 +718,97 @@ function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; ac ); } +// Live update status for the footer's restart prompt: seeded from getStatus, +// then streamed via updates:status (same idiom as UpdatesSection). +function useUpdateStatus(): UpdateStatus { + const [status, setStatus] = useState({ state: "idle" }); + useEffect(() => { + let live = true; + void aoBridge.updates.getStatus().then((s) => { + if (live) setStatus(s); + }); + const off = aoBridge.updates.onStatus(setStatus); + return () => { + live = false; + off?.(); + }; + }, []); + return status; +} + +// RestartToUpdateRow sits directly above the Settings row when an update is +// downloaded and staged. Transparent while fresh; orange (working tokens) once +// the main-process evaluator flags it escalated. Clicking installs immediately; +// the row itself is the prompt, so no confirmation dialog. Renders nothing in +// every other update state. +function RestartToUpdateRow() { + const status = useUpdateStatus(); + if (status.state !== "downloaded") return null; + const escalated = status.escalated === true; + return ( + + ); +} + +// Icon-rail variant of RestartToUpdateRow for the collapsed sidebar: icon-only +// with the two-line copy in the tooltip. +function RestartToUpdateRailButton() { + const status = useUpdateStatus(); + if (status.state !== "downloaded") return null; + const escalated = status.escalated === true; + return ( + + + + + + Restart to update{status.version ? ` ยท v${status.version} ready` : ""} + + + ); +} + function CreateProjectButton({ onCreateProject }: Pick) { return (