diff --git a/Agent.md b/Agent.md index 6df5a7b5..d21ec861 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (988) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/client/app.py b/emrg/client/app.py index 6c4d393a..154743ef 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -380,13 +380,6 @@ async def _reconnect(): import emrg ver = getattr(emrg, "__version__", "dev") chat.add("system", f"EMRG {ver} | {server_id}\nType /help for shortcuts, or just start chatting.") - # Auto update-check prompt (rant 2026-08-10T07:12:12): - # one-time, non-blocking — query daemon's cached latest - # release, show a status line, mark prompted (idempotent). - try: - await conn.send_command("update_check") - except Exception: - pass # never block chat on update check status.update(left=_status_left(session_title, session_id, current_model), center=server_id) term.set_title(f"{session_title or session_id} @ {project_name}") term.render(); continue @@ -1005,30 +998,6 @@ async def _reconnect(): term.render() continue - # Memory content (read) - if data.get("type") == "update_check": - # Auto update-check prompt (rant 2026-08-10T07:12:12): - # one-time, non-blocking status line; idempotent per version. - if data.get("has_update") and data.get("enabled"): - latest = data.get("latest_version", "") - prompted = data.get("prompted_version", "") - if latest and latest != prompted: - import emrg - ver = getattr(emrg, "__version__", "dev") - chat.add("system", - f"New version v{latest} available (current v{ver}) — " - f"https://github.com/argszero/emrg/releases") - status.update(center=server_id or "emrg") - try: - await conn.send_command( - "update_check_prompted", - {"version": latest}, - ) - except Exception: - pass - term.render() - continue - if data.get("type") == "memory_content": err = data.get("error", "") if err: diff --git a/emrg/config.py b/emrg/config.py index 9bb7b5d2..3460021d 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -39,21 +39,21 @@ class LlmConfig: @dataclass class UpdateConfig: - """Auto update-check settings (rants 2026-08-10T07:12:12, 2026-08-12T12:10:12). - - check: master switch — when false, the daemon never queries GitHub - (checking, downloading and prompting are all disabled). - ttl_hours: how often to re-check the latest release (default 1h; - rant 2026-08-12T12:10:12: host 指示 24h → 1h). - auto_download: when a newer version exists, download the current - platform's installer into ~/.emrg/updates/ in the background - (stream + Range resume + SHA256 verify). Installation is ALWAYS - user-initiated — never auto-installed. + """Auto-upgrade settings (rant 2026-08-20T12:33:59 — 自动升级重构). + + enabled: master switch — when false, the daemon never checks for new + releases and never triggers an upgrade session. + delay_minutes: how long after a release is published before it becomes + eligible for upgrade (default 1440 = 1 day; the host can set 1 for + immediate). Granularity is minutes (host chose A). The check + interval is NOT configurable — hard-coded 5 minutes in upgrade.py. + Old fields check / ttl_hours / auto_download are removed (the + download-installer mechanism is fully replaced by the agent-driven + local equivalent install). """ - check: bool = True - ttl_hours: int = 1 - auto_download: bool = True + enabled: bool = True + delay_minutes: int = 1440 @dataclass @@ -114,9 +114,8 @@ def load_config() -> EmrgConfig: update_data = data.get("update", {}) update = UpdateConfig( - check=update_data.get("check", True), - ttl_hours=update_data.get("ttl_hours", 1), - auto_download=update_data.get("auto_download", True), + enabled=update_data.get("enabled", True), + delay_minutes=update_data.get("delay_minutes", 1440), ) return EmrgConfig(llm=llm, update=update) @@ -125,9 +124,8 @@ def load_config() -> EmrgConfig: def load_update_config() -> UpdateConfig: """Load only the [update] section (rant 2026-08-10T07:12:12). - The daemon is constructed with just LlmConfig; this helper lets it read - the update-check switch without parsing the full config. Missing config - file or missing section → defaults (check=True, ttl=1h, auto_download=True). + The daemon constructs the UpgradeManager from this helper. Missing config + file or missing section → defaults (enabled=True, delay_minutes=1440). """ cfg_path = config_path() if not cfg_path.exists(): @@ -138,9 +136,8 @@ def load_update_config() -> UpdateConfig: return UpdateConfig() update_data = data.get("update", {}) return UpdateConfig( - check=update_data.get("check", True), - ttl_hours=update_data.get("ttl_hours", 1), - auto_download=update_data.get("auto_download", True), + enabled=update_data.get("enabled", True), + delay_minutes=update_data.get("delay_minutes", 1440), ) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 527a6e32..d881770a 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -710,76 +710,6 @@ vision = false return { authenticated: Boolean(frame.authenticated), user: frame.user || null }; }); - ipcMain.handle("emrg:updateCheck", async (_e, { force } = {}) => { - // Auto update-check (rant 2026-08-10T07:12:12): query daemon's cached - // latest release; force=true (rant 2026-08-11T09:18:16): settings - // manual check button — daemon runs a fresh GitHub fetch. - // rant 2026-08-12T12:10:12: response also carries the auto-download - // state (downloaded_version/path/sha) for the "ready to install" UI. - try { - const frame = await requireConn().sendCommandAndWait( - "update_check", - { force: Boolean(force) }, - 10000, - ); - return { - current_version: frame.current_version || "", - latest_version: frame.latest_version || "", - has_update: Boolean(frame.has_update), - prompted_version: frame.prompted_version || "", - downloaded_version: frame.downloaded_version || "", - downloaded_path: frame.downloaded_path || "", - downloaded_sha: frame.downloaded_sha || "", - enabled: Boolean(frame.enabled), - }; - } catch { - return { has_update: false, enabled: false, latest_version: "", current_version: "" }; - } - }); - - ipcMain.handle("emrg:updateInstall", async (_e, { path: p, version } = {}) => { - // Auto-update one-click install (rant 2026-08-12T12:10:12): the daemon - // already downloaded + SHA256-verified the installer into - // ~/.emrg/updates/. The user clicked "install" — launch the installer, - // then quit EMRG (the installer stops any remaining EMRG processes - // itself — stop-emrg.cmd / pkg install logic). Install is ALWAYS - // user-initiated; the download itself was automatic. - try { - if (!p || typeof p !== "string") return { ok: false, error: "missing path" }; - if (!fs.existsSync(p)) return { ok: false, error: "downloaded installer not found" }; - const platform = process.platform; - if (platform === "win32") { - // PrivilegesRequired=lowest → no UAC prompt for the exe itself. - const child = spawn(p, [], { detached: true, stdio: "ignore", windowsHide: true }); - child.unref(); - } else if (platform === "darwin") { - // `open ` mounts + launches the Installer.app flow. - const child = spawn("open", [p], { detached: true, stdio: "ignore" }); - child.unref(); - } else if (platform === "linux") { - fs.chmodSync(p, 0o755); - const child = spawn(p, [], { detached: true, stdio: "ignore" }); - child.unref(); - } else { - return { ok: false, error: `unsupported platform: ${platform}` }; - } - // Let the IPC reply flush, then close the GUI — the installer takes over. - setTimeout(() => { try { app.quit(); } catch { /* ignore */ } }, 500); - return { ok: true, version: String(version || "") }; - } catch (err) { - return { ok: false, error: String((err && err.message) || err) }; - } - }); - - ipcMain.handle("emrg:updateCheckPrompted", async (_e, { version }) => { - // Idempotency (rant 07:12:12 §4): record that the GUI showed the prompt - // for this version — same version never re-prompted. - try { - await requireConn().sendCommandAndWait("update_check_prompted", { version: String(version || "") }, 5000); - } catch { /* best-effort */ } - return { ok: true }; - }); - ipcMain.handle("emrg:githubConnect", async (_e, { token }) => { // Windows GCM rant Stage 2:PAT 授权 + setup-git(daemon github_connect) const frame = await requireConn().sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index f0acf06f..2e72bf9b 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -48,9 +48,6 @@ const api = { listRants: (payload) => ipcRenderer.invoke("emrg:listRants", payload), evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload), githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), - updateCheck: (payload) => ipcRenderer.invoke("emrg:updateCheck", payload), - updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload), - updateInstall: (payload) => ipcRenderer.invoke("emrg:updateInstall", payload), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 8706ebf4..adfb5613 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -253,10 +253,6 @@

设置

EMRG v0.2.8 · 🌱 已自我进化 0
- -
- -
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 8051426b..f0d9fdb3 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -124,11 +124,6 @@ const App = (() => { // 修复:boot 成功路径必须启用输入框(此前仅 done/cancelled/disconnected/error // 回调会调用 setComposerDisabled(false),形成"需先发消息才能启用输入框"死锁) setComposerDisabled(false); - // rant 2026-08-11T09:18:16:启动主动更新提示——不开设置也能看到新版本 - // (非阻塞系统消息,一次幂等;daemon 未就绪时静默失败) - if (window.EMRG_Dialogs?.promptUpdateAtStartup) { - Dialogs.promptUpdateAtStartup(); - } } catch (e) { Chat.addSystemMessage(_t("app.bootFailed", { msg: e.message })); } @@ -1509,13 +1504,6 @@ const App = (() => { } } break; - case "update_downloaded": - // rant 2026-08-12T12:10:12:daemon 后台自动下载 + 校验完新安装包 → - // 非阻塞提示"已就绪,点击安装"(设置 → 关于显示安装按钮) - Chat.addSystemMessage( - _t("app.updateReady", { latest: data.downloaded_version || "" }), - ); - break; case "group_cleared": Chat.groupNodes.delete(data.requestId); break; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 078fdcdc..01277ebf 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -233,11 +233,6 @@ const Dialogs = (() => { } catch { /* 元素缺失(测试桩)时忽略 */ } // Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新 await refreshGithubStatus(); - // Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows - // a one-time non-intrusive line when a newer release exists. - // rant 2026-08-11T09:18:16:手动"检查更新"按钮(force 重新检查) - initUpdateCheckButton(); - await refreshUpdateCheck(); } catch (e) { Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message })); } @@ -491,126 +486,6 @@ const Dialogs = (() => { } } - // Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one - // line in the about area, never a modal. - // force (rant 2026-08-11T09:18:16): settings manual check button — run a - // fresh GitHub fetch instead of returning the daemon's cached result. - // rant 2026-08-12T12:10:12: when the daemon already auto-downloaded a - // verified installer (downloaded_version), show a one-click install button - // instead of the plain Releases link. - // ⚠️ 局部变量命名 updEl(勿用 el——会遮蔽模块级 el() 元素工厂,el("a",…) - // 抛 TypeError → catch 吞掉 → 更新行永远 hidden,正是 #602 隐藏缺陷) - async function refreshUpdateCheck({ force = false } = {}) { - const updEl = $("about-update"); - if (!updEl) return; // 元素缺失(测试桩)时忽略 - try { - const u = await window.emrg.updateCheck({ force }); - if (!u || !u.enabled) { - updEl.classList.add("hidden"); - updEl.textContent = ""; - return; - } - updEl.textContent = ""; - let shown = false; - // ① 已自动下载 + 校验通过 → 一键安装按钮(rant 2026-08-12T12:10:12) - if (u.downloaded_version && u.downloaded_version !== u.current_version) { - const btn = el("button", { - type: "button", - class: "btn btn-sm btn-primary", - }, _t("settings.updateReady", { latest: u.downloaded_version })); - btn.addEventListener("click", () => showUpdateInstallConfirm(u)); - updEl.appendChild(btn); - shown = true; - } - // ② 有新版但未下载 → Releases 链接(幂等:同版本只提示一次) - if (!shown && u.has_update && u.latest_version && u.latest_version !== u.prompted_version) { - const link = el("a", { - href: "https://github.com/argszero/emrg/releases", - target: "_blank", - rel: "noopener", - }, _t("settings.updateAvailable", { latest: u.latest_version })); - updEl.appendChild(link); - shown = true; - try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } - } - updEl.classList.toggle("hidden", !shown); - } catch { - updEl.classList.add("hidden"); - updEl.textContent = ""; - } - } - - // Auto-update one-click install (rant 2026-08-12T12:10:12): the daemon - // downloaded + SHA256-verified the installer; the user clicks install → - // confirm (SmartScreen hint on Windows) → launch installer + quit EMRG - // (the installer stops any remaining EMRG processes itself). - function showUpdateInstallConfirm(u) { - const isWin = /win/i.test((navigator.platform || "") + (navigator.userAgent || "")); - showConfirm( - _t("settings.installTitle"), - _t(isWin ? "settings.installConfirmWin" : "settings.installConfirm", { latest: u.downloaded_version || "" }), - { - okText: _t("settings.installNow"), - danger: false, - onOk: async () => { - try { - const res = await window.emrg.updateInstall({ path: u.downloaded_path, version: u.downloaded_version }); - if (res && res.ok) { - Chat.addSystemMessage(_t("settings.installStarted", { latest: u.downloaded_version || "" })); - } else { - Chat.addSystemMessage(_t("settings.installFailed", { msg: (res && res.error) || "" })); - } - } catch { - Chat.addSystemMessage(_t("settings.installFailed", { msg: "" })); - } - }, - }, - ); - } - - // 设置页"检查更新"手动按钮(rant 2026-08-11T09:18:16):点击立即强制 - // 重新检查(不等 TTL 轮询),检查中显示"检查中…",完成后恢复按钮。 - function initUpdateCheckButton() { - const btn = $("about-update-check-btn"); - if (!btn) return; // 元素缺失(测试桩)时忽略 - btn.addEventListener("click", async () => { - const original = btn.textContent; - btn.disabled = true; - btn.textContent = _t("settings.checkingUpdate"); - try { - await refreshUpdateCheck({ force: true }); - } finally { - btn.disabled = false; - btn.textContent = original; - } - }); - } - - // 启动主动更新提示(rant 2026-08-11T09:18:16):GUI 启动成功路径调用, - // 不依赖打开设置对话框——有新版本且未提示过时输出一条非阻塞系统消息 - // (对齐 TUI 启动 system 行)。rant 2026-08-12T12:10:12:若 daemon 已 - // 自动下载好安装包,则提示"已就绪,点击安装"。boot 时 daemon 可能未 - // 就绪 → 静默失败。 - async function promptUpdateAtStartup() { - try { - const u = await window.emrg.updateCheck({ force: false }); - if (!u || !u.enabled) return; - // 已自动下载 + 校验通过 → "已就绪,点击安装"(设置 → 关于) - if (u.downloaded_version && u.downloaded_version !== u.current_version) { - Chat.addSystemMessage( - _t("app.updateReady", { latest: u.downloaded_version }), - ); - return; - } - if (!u.has_update || !u.latest_version) return; - if (u.latest_version === u.prompted_version) return; - Chat.addSystemMessage( - _t("app.updateAvailable", { latest: u.latest_version }), - ); - // 幂等:同版本只提示一次 - try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } - } catch { /* boot-time daemon may not be ready — silent */ } - } // ── 定时任务管理(rant 2026-08-12T18:23:15 P3:GUI 任务/自定义类型 CRUD) ── // 决策点执行:①内置类型只读 ②被任务引用的自定义类型拒绝删除 ③项目仅限已注册 @@ -1652,10 +1527,6 @@ const Dialogs = (() => { initGithubSection, initDeviceDialog, refreshGithubStatus, - refreshUpdateCheck, - initUpdateCheckButton, - promptUpdateAtStartup, - showUpdateInstallConfirm, // rant 12:10:12:已下载安装包 → 一键安装确认 initOpenSessionDialog, // P5:打开会话对话框初始化 showOpenSessionDialog, // P5:两步打开会话 initNewSessionDialog, // P5 slice 2:新建会话对话框初始化 diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 32fe3fc6..ce5c6444 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -153,18 +153,6 @@ const I18N = (() => { "settings.githubConnectedStatus": "已连接 @{user}", "settings.githubNotConnected": "未连接", "settings.githubStatusFailed": "状态获取失败", - "settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)", - "settings.checkUpdate": "检查更新", - "settings.checkingUpdate": "检查中…", - "settings.updateReady": "新版本 v{latest} 已就绪 —— 点击安装", - "settings.installNow": "点击安装", - "settings.installTitle": "安装新版本", - "settings.installConfirm": "安装 v{latest}?EMRG 将自动退出,由安装器接管。", - "settings.installConfirmWin": "安装 v{latest}?EMRG 将自动退出。若出现 SmartScreen 提示,点击「更多信息 → 仍要运行」。", - "settings.installStarted": "正在启动安装 v{latest}…", - "settings.installFailed": "安装启动失败:{msg}", - "app.updateReady": "新版本 v{latest} 已下载 —— 设置 → 关于 → 点击安装", - "app.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装):https://github.com/argszero/emrg/releases", "settings.githubConnecting": "连接中…", "settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)", "settings.githubConnectFailed": "GitHub 连接失败:{msg}", @@ -582,18 +570,6 @@ const I18N = (() => { "settings.githubConnectedStatus": "Connected as @{user}", "settings.githubNotConnected": "Not connected", "settings.githubStatusFailed": "Failed to load status", - "settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)", - "settings.checkUpdate": "Check for updates", - "settings.checkingUpdate": "Checking…", - "settings.updateReady": "New version v{latest} ready — click to install", - "settings.installNow": "Install now", - "settings.installTitle": "Install new version", - "settings.installConfirm": "Install v{latest}? EMRG will quit and the installer takes over.", - "settings.installConfirmWin": "Install v{latest}? EMRG will quit. If SmartScreen appears, click 'More info → Run anyway'.", - "settings.installStarted": "Launching installer for v{latest}…", - "settings.installFailed": "Failed to launch installer: {msg}", - "app.updateReady": "New version v{latest} downloaded — install it from Settings → About", - "app.updateAvailable": "New version v{latest} available — https://github.com/argszero/emrg/releases (no auto-install)", "settings.githubConnecting": "Connecting…", "settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)", "settings.githubConnectFailed": "GitHub connect failed: {msg}", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 28cdacfd..e97a74a8 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -147,7 +147,6 @@ const ELEMENT_IDS = [ "result-panel", "result-list", "result-toggle", "result-tabs", "result-tab-files", "result-tab-artifacts", "result-tabbar", "result-files", "result-viewer", "result-resizer", "growth-card", "growth-count", "about-recent", - "about-update", "about-update-check-btn", "github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss", "toast", "toast-msg", // rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) @@ -236,7 +235,6 @@ function makeSandbox(overrides = {}) { closePreview: async () => ({ ok: true }), panelResized: async () => ({ ok: true }), getPreviewState: async () => ({ path: null }), // P2.3:崩溃恢复拉取 - updateInstall: async () => ({ ok: true }), // rant 12:10:一键安装 IPC 默认桩 sendRant: async () => ({}), listRants: async () => [], ...overrides, @@ -2309,90 +2307,6 @@ test("B3: 发送消息清除该会话草稿;新会话从空草稿开始", asyn assert.strictEqual(newDraft, "", "new session starts with empty draft"); }); -test("rant 09:18:启动主动更新提示——有新版本且未提示过 → 系统消息一次", async () => { - const { ctx } = makeSandbox({ - updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23" }), - updateCheckPrompted: async () => ({}), - }); - await tick(); - const r = await vm.runInContext(`(async function() { - let prompted = null; - window.emrg.updateCheckPrompted = async (p) => { prompted = p; }; - await EMRG_Dialogs.promptUpdateAtStartup(); - const texts = []; - for (let i = 0; i < $("workspace").children.length; i++) texts.push($("workspace").children[i].textContent); - return { n: texts.length, joined: texts.join("|"), prompted: JSON.stringify(prompted) }; - })()`, ctx); - assert.ok(r.n >= 1, "应输出一条系统消息"); - assert.ok(r.joined.includes("0.2.99"), `系统消息应含新版本号,实际 ${r.joined}`); - assert.ok(r.prompted.includes("0.2.99"), "应记录已提示版本(幂等)"); -}); - -test("rant 09:18:启动提示幂等——已提示过/未启用/无更新 → 不提示", async () => { - const { ctx } = makeSandbox({ - updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "0.2.99", current_version: "0.2.23" }), - }); - await tick(); - const r = await vm.runInContext(`(async function() { - await EMRG_Dialogs.promptUpdateAtStartup(); - return { n: $("workspace").children.length }; - })()`, ctx); - assert.strictEqual(r.n, 0, "已提示过同版本 → 不得重复提示"); -}); - -test("rant 09:18:设置页手动检查按钮——点击强制重新检查并显示检查中", async () => { - const { ctx } = makeSandbox({ - updateCheck: async (payload) => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23", _force: payload && payload.force }), - updateCheckPrompted: async () => ({}), - }); - await tick(); - const r = await vm.runInContext(`(async function() { - const calls = []; - window.emrg.updateCheck = async (payload) => { - calls.push(payload || {}); - return { enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "" }; - }; - const btn = $("about-update-check-btn"); - btn.textContent = "检查更新"; // 模拟 index.html 静态文案(沙箱不加载 HTML) - EMRG_Dialogs.initUpdateCheckButton(); - btn.click(); - const during = { text: btn.textContent, disabled: btn.disabled }; - await new Promise((res) => setTimeout(res, 20)); - return { - calls: JSON.stringify(calls), - duringText: during.text, - afterText: btn.textContent, - afterDisabled: btn.disabled, - updateShown: !$("about-update").classList.contains("hidden"), - }; - })()`, ctx); - const calls = JSON.parse(r.calls); - assert.strictEqual(calls.length, 1, "点击按钮应触发一次 updateCheck"); - assert.strictEqual(calls[0].force, true, "手动检查必须带 force:true(跳过 TTL 缓存)"); - assert.strictEqual(r.duringText, "检查中…", "检查中按钮应显示“检查中…”"); - assert.strictEqual(r.afterText, "检查更新", "完成后按钮文案恢复"); - assert.strictEqual(r.afterDisabled, false, "完成后按钮恢复可用"); - assert.strictEqual(r.updateShown, true, "有新版本应显示 about-update 行"); -}); - -test("rant 09:18:refreshUpdateCheck 透传 force 参数", async () => { - const { ctx } = makeSandbox({ - updateCheck: async (payload) => ({ enabled: false, has_update: false, latest_version: "", prompted_version: "" }), - }); - await tick(); - const r = await vm.runInContext(`(async function() { - const calls = []; - window.emrg.updateCheck = async (payload) => { calls.push(payload || {}); return { enabled: false, has_update: false, latest_version: "", prompted_version: "" }; }; - await EMRG_Dialogs.refreshUpdateCheck({ force: true }); - await EMRG_Dialogs.refreshUpdateCheck(); - return JSON.stringify(calls); - })()`, ctx); - const calls = JSON.parse(r); - assert.strictEqual(calls.length, 2); - assert.strictEqual(calls[0].force, true, "refreshUpdateCheck({force:true}) 应透传 force"); - assert.strictEqual(calls[1].force, false, "默认 refreshUpdateCheck() 不带 force"); -}); - // ── P2.3 + P3.4(rant 2026-08-11T12:20:35):HTML 预览 WebContentsView ────────── test("P3.4:HTML tab 打开 → previewHtml IPC + 占位渲染(不走 read_file)", async () => { @@ -2522,76 +2436,6 @@ test("P2.3:handlePreviewState 幂等——已打开路径仅激活不重复开 assert.ok(calls.previewHtml.length >= 2, "恢复激活应重新 previewHtml(bounds/loadURL 同步)"); }); -// ── rant 2026-08-12T12:10:12:自动下载 + GUI 一键安装 ── - -test("rant 12:10:已下载安装包 → 设置页一键安装按钮 → 确认 → updateInstall", async () => { - const installCalls = []; - const { ctx, els } = makeSandbox({ - updateCheck: async () => ({ - enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", - current_version: "0.2.27", - downloaded_version: "0.2.99", - downloaded_path: "/home/u/.emrg/updates/EMRG-0.2.99-windows-x64.exe", - }), - updateInstall: async (p) => { installCalls.push(p); return { ok: true }; }, - }); - await tick(); - await vm.runInContext("EMRG_Dialogs.refreshUpdateCheck()", ctx); - await tick(); - const updEl = els["about-update"]; - assert.strictEqual(updEl.classList.contains("hidden"), false, "update row visible"); - assert.ok(updEl.children.length >= 1, "install button rendered"); - const btn = updEl.children[0]; - assert.ok((btn.textContent || "").includes("0.2.99"), `button text has version: "${btn.textContent}"`); - assert.ok(btn.className.includes("btn"), `button styled as button: "${btn.className}"`); - btn.click(); // → 确认对话框 - await tick(); - assert.strictEqual(els["confirm-dialog"].open, true, "confirm dialog shown"); - assert.ok((els["confirm-message"].textContent || "").includes("0.2.99"), "confirm mentions version"); - await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx); - await tick(); - assert.strictEqual(installCalls.length, 1, "updateInstall called once"); - assert.strictEqual(installCalls[0].path, "/home/u/.emrg/updates/EMRG-0.2.99-windows-x64.exe", "downloaded path passed"); - assert.strictEqual(installCalls[0].version, "0.2.99", "version passed"); -}); - -test("rant 12:10:启动提示——已下载 → 系统消息“已就绪”(非更新链接)", async () => { - const { ctx } = makeSandbox({ - updateCheck: async () => ({ - enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", - current_version: "0.2.27", - downloaded_version: "0.2.99", - downloaded_path: "/home/u/.emrg/updates/EMRG-0.2.99-macos-arm64.pkg", - }), - }); - await tick(); - const r = await vm.runInContext(`(async function() { - await EMRG_Dialogs.promptUpdateAtStartup(); - const texts = []; - for (let i = 0; i < $("workspace").children.length; i++) texts.push($("workspace").children[i].textContent); - return texts.join("|"); - })()`, ctx); - assert.ok(r.includes("0.2.99"), `ready message contains version: "${r}"`); - assert.ok(r.includes("已下载") || r.includes("downloaded"), `ready wording: "${r}"`); -}); - -test("rant 12:10:downloaded_version == 当前版本 → 无安装按钮(退化更新链接)", async () => { - const { ctx, els } = makeSandbox({ - updateCheck: async () => ({ - enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", - current_version: "0.2.27", downloaded_version: "0.2.27", - }), - }); - await tick(); - await vm.runInContext("EMRG_Dialogs.refreshUpdateCheck()", ctx); - await tick(); - const updEl = els["about-update"]; - assert.strictEqual(updEl.classList.contains("hidden"), false, "update row visible"); - assert.strictEqual(updEl.children.length, 1, "single element (no install button)"); - const child = updEl.children[0]; - assert.ok(!child.className.includes("btn"), "not a button when downloaded == current"); - assert.ok((child.attributes.href || "").includes("releases"), "falls back to the Releases link"); -}); // ── rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) ── test("P3:设置面板打开 → 任务列表渲染(名称/类型/项目/间隔)+ 编辑预填 → taskUpdate", async () => { diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 8a60e3ef..bd44b172 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -23,6 +23,7 @@ import socket as _socket import subprocess import sys +import time from datetime import datetime from pathlib import Path from typing import Optional @@ -386,11 +387,14 @@ async def serve(self) -> None: # host-modified skill copies. self._skills_ttl_task = asyncio.create_task(self._skills_ttl_loop()) - # Auto update-check prompt (rant 2026-08-10T07:12:12): runs at startup - # + every [update] ttl_hours (default 24h). ONLY checks the latest - # release and persists state — clients (TUI/GUI) decide how to show - # the one-time prompt. No auto download/install, silent on failure. - self._update_check_task = asyncio.create_task(self._update_check_loop()) + # Auto-upgrade trigger (rant 2026-08-20T12:33:59 — 自动升级重构): + # every 5 minutes the UpgradeManager checks GitHub releases, delay- + # filters, compares with install/version.txt and — when a newer + # eligible tag exists — starts an agent session ("emrg-upgrade") + # that performs the local equivalent install. All logic lives in + # emrg/server/upgrade.py; the daemon only runs the tick loop and + # provides the session-runner callback. + self._upgrade_tick_task = asyncio.create_task(self._upgrade_tick_loop()) try: await self._server.serve_forever() @@ -427,10 +431,10 @@ async def _shutdown_all(self, pid_file: Path) -> None: ) steps: list[tuple[str, bool]] = [] - # 1. Background task loops (skills TTL, update check, port keepalive) + # 1. Background task loops (skills TTL, upgrade tick, port keepalive) for task, name in ( (self._skills_ttl_task, "skills-ttl loop"), - (self._update_check_task, "update-check loop"), + (self._upgrade_tick_task, "upgrade-tick loop"), (self._port_keepalive_task, "port-keepalive loop"), ): try: @@ -563,104 +567,67 @@ async def _skills_ttl_loop(self) -> None: logger.warning("skills update errors: %s", result["errors"]) await asyncio.sleep(_UPDATE_TTL_SECONDS) - async def _update_check_loop(self) -> None: - """Background auto update-check + auto-download (rants 07:12:12, 12:10:12). - - Runs at startup + every [update] ttl_hours (default 1h). Checks the - latest release via api.github.com and persists state to - ~/.emrg/.last_update_check.json. When a newer version exists and - [update] auto_download is enabled, the installer is downloaded in a - background task (stream + Range resume + SHA256 verify) — NEVER - auto-installed. Failures are silent (never crash, never log noise); - the next TTL retries. Disabled entirely when [update] check = false. + async def _upgrade_tick_loop(self) -> None: + """Auto-upgrade trigger (rant 2026-08-20T12:33:59 — 自动升级重构). + + Constructs the UpgradeManager once, then ticks every 5 minutes + (TICK_INTERVAL is hard-coded, not configurable). The manager checks + releases → delay-filter → compare with install/version.txt → start + an agent session when a newer eligible tag exists. All judgment + (dependencies / backup / GUI / retry) is the agent's, template- + driven; the program's only state is the in-flight re-entry flag. + Failures are silent — the next tick retries naturally. + + The FIRST tick runs only after TICK_INTERVAL (5 min): the daemon + must never hammer the GitHub API at startup (test harnesses boot + many servers; each immediate tick would fire a real network request + and destabilize timing). """ from emrg.config import load_update_config - from emrg.update_check import ( - load_state, - run_update_check_once, - should_check, - ) + from emrg.server.upgrade import TICK_INTERVAL, UpgradeManager - update_cfg = load_update_config() - if not update_cfg.check: - logger.debug("auto update-check disabled by config ([update] check=false)") - return - - ttl = max(3600, int(update_cfg.ttl_hours or 1) * 3600) + self._upgrade_manager = UpgradeManager( + load_update_config(), self._run_upgrade_session + ) while True: - state = load_state() - if should_check(state, ttl): - result = await run_update_check_once() - if result.get("checked"): - logger.debug( - "update check: latest=%s", result.get("latest_version") - ) - await self._maybe_auto_download( - result.get("latest_version"), update_cfg.auto_download - ) - await asyncio.sleep(ttl) - - async def _maybe_auto_download(self, latest_version: str, auto_download: bool) -> None: - """Kick off a background installer download when a newer version exists. - - rant 2026-08-12T12:10:12: auto-download runs in its own task so the - check loop / chat is never blocked. Skipped when auto_download is - disabled, the version is not newer than the running one, or the same - version is already downloaded (and verified). + await asyncio.sleep(TICK_INTERVAL) + try: + await self._upgrade_manager.tick() + except Exception: + logger.debug("upgrade tick failed (retry next tick)", exc_info=True) + + async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) -> None: + """Public session-runner used by UpgradeManager (and a candidate entry + point for the scheduler's task capability — same ability, two entries, + host decision 2026-08-20T12:33:59). + + Reuses the existing session queueing semantics: when the session is + busy the request is queued (pending) and executed when free. Runs + headless — _run_tool_loop only uses _broadcast, no real ws client is + needed, so no UI is involved. The upgrade session id is fixed + (emrg-upgrade) for traceability. """ - if not auto_download or not latest_version: - return - import emrg - from emrg.update_check import ( - is_newer, - load_state, - parse_version, + req = TaskRequest( + id=f"upgrade-{int(time.time())}", + session_id=session_id, + cwd=cwd, + prompt=prompt, + timestamp="", ) - - state = load_state() - current = getattr(emrg, "__version__", "0") - if not is_newer(parse_version(latest_version), parse_version(current)): + if self._session_busy.get(session_id): + # Queue per existing semantics (host decision A: busy → pending, + # execute when free). A queued-but-never-drained upgrade is + # re-triggered by the next tick (version.txt unchanged). + self._session_pending.setdefault(session_id, []).append((req, True)) return - if state.get("downloaded_version") == latest_version: - return # already downloaded + verified - asyncio.create_task(self._auto_download_update(latest_version)) - - async def _auto_download_update(self, version: str) -> None: - """Background installer download + state persist + client notify. - - Never raises; failures are silent and retried at the next TTL. On - success the downloaded_* fields are persisted to the update state - file and connected clients get an update_downloaded broadcast so the - GUI can show the "ready to install" prompt (rant 2026-08-12T12:10:12). - """ - from emrg.update_check import ( - download_release_asset, - load_state, - save_state, - ) - + session = self._get_or_create_session(session_id, Path(cwd)) + self._session_busy[session_id] = True + cancel_event = asyncio.Event() try: - result = await download_release_asset(version) + await self._run_tool_loop_locked(req, None, session, cancel_event, allow_tools=True) except Exception: - logger.debug("update auto-download failed (retry next TTL)", exc_info=True) - return - if not result: - return # silent — next TTL retries - try: - state = load_state() - state.update(result) - save_state(state) - except Exception: - pass - logger.info( - "update auto-downloaded: %s -> %s", - result.get("downloaded_version"), - result.get("downloaded_path"), - ) - try: - await self._broadcast_all({"type": "update_downloaded", **result}) - except Exception: - pass + logger.debug("upgrade session failed (retry next tick)", exc_info=True) + self._session_busy[session_id] = False def _evolution_count(self) -> int: """Total completed evolution cycles across scheduler handlers + disk. @@ -1849,58 +1816,6 @@ async def _process_message( auth = await self._check_github_auth() await self._send(ws, {"type": "github_status", **auth}) - elif msg_type == "update_check": - # Auto update-check prompt (rant 2026-08-10T07:12:12): TUI/GUI - # query the daemon's latest known release. Returns the cached - # latest_version (populated at startup + every TTL) plus a - # has_update flag computed against the running version. No auto - # download/install; the client shows a one-time prompt. - # force:true (rant 2026-08-11T09:18:16) — GUI manual check - # button: run a fresh GitHub fetch first instead of the cache. - import emrg - from emrg.config import load_update_config - from emrg.update_check import ( - is_newer, - load_state, - parse_version, - run_update_check_once, - ) - - if msg.get("force"): - try: - await run_update_check_once() - except Exception: - logger.debug("forced update check failed", exc_info=True) - current = getattr(emrg, "__version__", "0") - state = load_state() - latest = state.get("latest_version") or "" - has_update = bool( - latest and is_newer(parse_version(latest), parse_version(current)) - ) - await self._send(ws, { - "type": "update_check", - "current_version": current, - "latest_version": latest, - "has_update": has_update, - "prompted_version": state.get("prompted_version") or "", - # rant 2026-08-12T12:10:12: auto-download state — GUI shows the - # "ready to install" button when downloaded_version is newer. - "downloaded_version": state.get("downloaded_version") or "", - "downloaded_path": state.get("downloaded_path") or "", - "downloaded_sha": state.get("downloaded_sha") or "", - "enabled": load_update_config().check, - }) - - elif msg_type == "update_check_prompted": - # Idempotency (rant 07:12:12 §4): a client records that it showed - # the prompt for this version — same version never re-prompted. - from emrg.update_check import load_state, mark_prompted - - version = msg.get("version", "") - if version: - mark_prompted(load_state(), version) - await self._send(ws, {"type": "update_check_prompted", "ok": True}) - elif msg_type == "github_connect": # Windows GCM rant Stage 2: GUI PAT auth — gh auth login # --with-token + gh auth setup-git (git no longer touches GCM). diff --git a/emrg/server/prompts/upgrade_prompt.j2 b/emrg/server/prompts/upgrade_prompt.j2 new file mode 100644 index 00000000..fd39973f --- /dev/null +++ b/emrg/server/prompts/upgrade_prompt.j2 @@ -0,0 +1,79 @@ +You are EMRG's upgrade agent. Your job: perform a local equivalent install of +target version {{ target_tag }} — with exactly the same result as if the host +had downloaded and run that release's installer package — WITHOUT downloading +any installer, using the local evolution repo clone. + +Context: +- source_repo: {{ source_repo }} (the evolution repo — READ-ONLY source) +- upgrade_work: {{ upgrade_work }} (your isolated working clone) +- install_dir: {{ install_dir }} (live installation to be upgraded) +- version_file: {{ version_file }} +- target_tag: {{ target_tag }} +- current_version (from {{ version_file }}): {{ current_version }} +- delay_minutes: {{ delay_minutes }} +- gui_src: {{ gui_src }} (evolution repo's emrg/gui — GUI update source) +- backup_dir: {{ backup_dir }} + +Follow these steps in order: + +## 1. Prepare the working clone +- If {{ upgrade_work }} does not exist: `git clone {{ source_repo }} {{ upgrade_work }}`. +- If it exists: `git fetch` inside it (sync all tags/branches from the + source repo). +- Then `git checkout {{ target_tag }}` — you now have the target version's + complete source, including packaging/make-installer.sh, build-runtime.sh, + bin/ launcher scripts, and the dist/runtime layout. + +## 2. Understand before touching (critical) +Read the source you checked out. Fully understand: +- the installer's install logic (make-installer.sh / build-runtime.sh / bin + launcher scripts / dist/runtime structure), +- the current project architecture and details, +- the current state of {{ install_dir }} (version.txt, source/, bin/, lib/, + assets/). +Do NOT start installing until you understand what "equivalent to the +release installer" means for this version. + +## 3. Equivalent install — bring {{ install_dir }} to the same state as the + {{ target_tag }} release installer would produce +- `{{ install_dir }}/source/emrg/` ← the `emrg/` source tree of the target + tag, using the same packaging rules as build-runtime.sh's source bundle: + exclude emrg/gui/node_modules, emrg/gui/dist, __pycache__ and *.pyc; + include py.typed and LICENSE. +- `{{ install_dir }}/lib/`: YOUR decision which dependencies to remove, + update or add — the target tag's pyproject.toml dependencies are the + source of truth. Operate as needed (pip --target install/uninstall/update). +- `{{ install_dir }}/version.txt` ← {{ target_tag }} (the version). +- `{{ install_dir }}/bin/` launcher scripts (emrg/emrgd/emrg.cmd/emrgd.cmd + etc.) — sync if the target tag changed them. +- GUI as needed: whether `~/Applications/EMRG.app` is updated is YOUR call — + compare the GUI artifact versions; if needed, build locally with + electron-builder and replace; otherwise skip. Nothing in the program + hard-codes GUI update logic. + +## 4. Backup & rollback +Before touching anything, back up the current {{ install_dir }}/source (and +the lib files you will touch) to {{ backup_dir }}//. On +failure, restore from there. The backup/restore approach is your decision; +a failed upgrade is simply re-triggered on the next 5-minute check (the +program's natural retry loop), so prefer a straightforward backup that +covers everything you are about to modify. + +## 5. Do NOT restart the daemon +After the upgrade completes, do NOT restart the daemon — the new code takes +effect on the host's next manual restart. In your final reply to the +session, explicitly tell the host: upgrade to {{ target_tag }} is complete +— please restart EMRG manually to apply it. + +## 6. Safety boundaries +- Do NOT modify the evolution repo {{ source_repo }}'s worktree or branches + (read-only source). All write operations happen in {{ upgrade_work }} / + {{ install_dir }} / {{ backup_dir }}. +- Do NOT touch code signing / notarization (release-side responsibility). +- The MANIFESTO highest principle applies: never write, restore or introduce + any test/script/code path that stops or restarts emrg server / emrgd. + Step 5 ("do not restart the daemon") is the natural extension of that + principle. +- If you discover the target tag has unmet prerequisites or the install is + impossible, report clearly in the session and leave the installation + untouched (the program will retry next check). diff --git a/emrg/server/upgrade.py b/emrg/server/upgrade.py new file mode 100644 index 00000000..b12f4e77 --- /dev/null +++ b/emrg/server/upgrade.py @@ -0,0 +1,211 @@ +"""Automatic upgrade trigger (rant 2026-08-20T12:33:59 — 自动升级重构). + +Design (host-specified boundaries, verbatim intent): +- The upgrade does NOT download an installer package, but the effect must be + fully equivalent to "installing the corresponding release installer". +- The local evolution repo (~/.emrg/evolution/emrg, a git repo with all + tags/full history) already has the latest code — upgrade takes it from + there. +- The PROGRAM only triggers: query GitHub releases API → delay-filter → + compare with local install/version.txt → render upgrade_prompt.j2 → start + an agent session ("emrg-upgrade"). No success/failure judgment, no state + files, no retries, no version.txt comparison for success — the agent does + all of that, template-driven. The program's ONLY state is the in-flight + re-entry flag. +- The old mechanism (emrg/update_check.py: download installer / state file / + check-TTL) is fully removed — parse_version / is_newer are migrated here. +""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Optional + +import httpx + +from emrg.config import UpdateConfig + +logger = logging.getLogger(__name__) + +# ── Constants ────────────────────────────────────────────────────────────── +# releases list (per_page=30, includes tag_name + published_at) — never the +# /latest endpoint: delay filtering needs published_at of every recent release. +RELEASES_URL = "https://api.github.com/repos/argszero/emrg/releases?per_page=30" +# Fixed work directory: isolated clone of the evolution repo — the agent can +# freely checkout any tag without touching the evolution task's worktree. +UPGRADE_WORK_DIR = Path.home() / ".emrg" / "upgrade-work" / "emrg" +INSTALL_DIR = Path.home() / ".emrg" / "install" +VERSION_FILE = INSTALL_DIR / "version.txt" +BACKUP_DIR = Path.home() / ".emrg" / "upgrade-backup" +GUI_SRC = Path(__file__).parent.parent / "gui" # evolution repo's emrg/gui +# Hard-coded 5-minute check interval (host: not configurable). +TICK_INTERVAL = 300 +CHECK_TIMEOUT_SECONDS = 10.0 +# Fixed upgrade session id — traceable, one session per upgrade. +SESSION_ID = "emrg-upgrade" + + +def parse_version(tag: str) -> tuple: + """Parse a version tag like 'v0.2.18' into a numeric tuple for comparison. + + Migrated from the removed emrg/update_check.py (rant 2026-08-20T12:33:59). + Only dot-separated pieces that are entirely digits are kept; the first + piece with any non-digit (prerelease/build suffix like '-beta1' or + '18-rc.2') terminates parsing — prerelease tags can never compare as + newer than a released version. Unparseable input → () (never newer). + """ + if not tag: + return () + s = tag.strip() + if s.startswith("v"): + s = s[1:] + parts = [] + for piece in s.split("."): + if piece.isdigit(): + parts.append(int(piece)) + else: + break # prerelease/build suffix — stop, drop the rest + return tuple(parts) + + +def is_newer(latest: tuple, current: tuple) -> bool: + """True iff latest > current (pure tuple comparison).""" + return bool(latest) and latest > current + + +def _published_epoch(published_at: str) -> Optional[float]: + """ISO published_at → epoch seconds; None on unparseable input.""" + try: + iso = published_at.replace("Z", "+00:00") + return datetime.fromisoformat(iso).timestamp() + except Exception: + return None + + +class UpgradeManager: + """Program-side upgrade trigger (all logic lives here — daemon only + references it; the daemon must NOT grow upgrade logic, host decision). + + tick() is called by the daemon every TICK_INTERVAL seconds. + """ + + def __init__(self, config: UpdateConfig, run_session_cb: Callable): + self._config = config + self._run_session_cb = run_session_cb # daemon-provided session runner + self._inflight = False # upgrade session in progress (re-entry guard) + + # ── Public: called by the daemon every 5 minutes ────────────────────── + async def tick(self) -> None: + if not self._config.enabled: + return + if self._inflight: + return # re-entry guard: skip while an upgrade session runs + target = await self._find_target_tag() + if not target: + return # nothing eligible this round + local = self._read_local_version() + if local == target.lstrip("v"): + return # already at target — nothing to do + await self._trigger(target) + + # ── Target discovery ────────────────────────────────────────────────── + async def _find_target_tag(self) -> Optional[str]: + """Delay-filtered newest eligible release tag, or None. + + Network failure / non-200 / 429 → None (silent, retried next tick — + 5-minute cadence is well below the unauthenticated 60/h rate limit, + no complex backoff needed). + """ + try: + async with httpx.AsyncClient( + timeout=CHECK_TIMEOUT_SECONDS, follow_redirects=True + ) as client: + resp = await client.get(RELEASES_URL) + if resp.status_code != 200: + return None + data = resp.json() + if not isinstance(data, list): + return None + except Exception: + logger.debug("upgrade: releases fetch failed (retry next tick)", exc_info=True) + return None + + cutoff = time.time() - self._config.delay_minutes * 60 + best_tag: Optional[str] = None + best_ver: tuple = () + for rel in data: + if not isinstance(rel, dict): + continue + tag = rel.get("tag_name") or "" + published_ts = _published_epoch(rel.get("published_at") or "") + if published_ts is None or published_ts > cutoff: + continue # unparseable or not yet eligible (delay window) + ver = parse_version(tag) + if not ver: + continue + if ver > best_ver: + best_tag = tag + best_ver = ver + return best_tag + + # ── Local state ─────────────────────────────────────────────────────── + def _read_local_version(self) -> str: + """Current installed version from install/version.txt ("" on failure). + + Normalized without the leading 'v' — the target tag keeps its 'v' + prefix when passed to the template/agent (git tag lookup needs it). + """ + try: + text = VERSION_FILE.read_text(encoding="utf-8").strip() + except OSError: + return "" + return text.lstrip("v") + + # ── Trigger ─────────────────────────────────────────────────────────── + async def _trigger(self, tag: str) -> None: + """Render the upgrade prompt and start the agent session. + + in-flight is set BEFORE the session starts and cleared in finally — + a daemon restart kills the session and the next tick re-triggers + naturally (version.txt unchanged → trigger again; correct semantics). + """ + self._inflight = True + try: + prompt = self._render_prompt(tag) + await self._run_session_cb( + session_id=SESSION_ID, + cwd=str(UPGRADE_WORK_DIR), + prompt=prompt, + ) + except Exception: + logger.debug("upgrade: session trigger failed (retry next tick)", exc_info=True) + finally: + self._inflight = False + + def _render_prompt(self, target_tag: str) -> str: + """Render upgrade_prompt.j2 (same live-reload FileSystemLoader + mechanism as system.j2 / vibe_check.j2 — host edits take effect + without a daemon restart).""" + import jinja2 # type: ignore[import-untyped] + + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(Path(__file__).parent / "prompts"), + autoescape=False, + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template("upgrade_prompt.j2") + return template.render( + source_repo=str(Path.home() / ".emrg" / "evolution" / "emrg"), + upgrade_work=str(UPGRADE_WORK_DIR), + install_dir=str(INSTALL_DIR), + target_tag=target_tag, + current_version=self._read_local_version(), + delay_minutes=self._config.delay_minutes, + version_file=str(VERSION_FILE), + gui_src=str(GUI_SRC), + backup_dir=str(BACKUP_DIR), + ) diff --git a/emrg/update_check.py b/emrg/update_check.py deleted file mode 100644 index 07e4374a..00000000 --- a/emrg/update_check.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Automatic update-check + notify + auto-download (rants 2026-08-10T07:12:12, -2026-08-12T12:10:12). - -Design (host-specified boundaries): -- CHECK: look for new versions on api.github.com (never prereleases). -- PROMPT: one prompt per version, idempotent via state file, silent on - network failure (retry at next TTL). -- DOWNLOAD (rant 2026-08-12T12:10:12): when a newer version is found and - [update] auto_download is enabled, the daemon downloads the current - platform's installer asset in the background — stream + Range resume, - SHA256 verify against the release asset digest, landed in - ~/.emrg/updates/. NEVER auto-installs: the GUI prompts the user and the - user clicks to install. -- [update] check=false disables everything (including download). - -Check source: api.github.com (github.com:443 / raw.githubusercontent.com are -blocked on the host network — see git_utils / skills installer patterns). -""" - -from __future__ import annotations - -import hashlib -import json -import os -import platform -import time -from pathlib import Path -from typing import Optional - -import httpx - -from emrg.config import config_dir - -# Default TTL between checks (seconds). Host-configurable via [update] ttl_hours -# (rant 2026-08-12T12:10:12: default 24h → 1h). -DEFAULT_TTL_SECONDS = 3600 -# API endpoint — releases/latest never includes prereleases (semver-satisfying). -RELEASES_LATEST_URL = "https://api.github.com/repos/argszero/emrg/releases/latest" -# Direct asset download base (no API rate limits on release downloads). -DOWNLOAD_BASE_URL = "https://github.com/argszero/emrg/releases/download" -CHECK_TIMEOUT_SECONDS = 10.0 -DOWNLOAD_TIMEOUT_SECONDS = 600.0 - -STATE_FILE_NAME = ".last_update_check.json" -UPDATES_DIR_NAME = "updates" - - -def parse_version(tag: str) -> tuple: - """Parse a version tag like 'v0.2.18' into a numeric tuple for comparison. - - Only dot-separated pieces that are entirely digits are kept; the first - piece with any non-digit (prerelease/build suffix like '-beta1' or - '18-rc.2') terminates parsing — prerelease tags can never compare as - newer than a released version. Unparseable input → () (never newer). - """ - if not tag: - return () - s = tag.strip() - if s.startswith("v"): - s = s[1:] - parts = [] - for piece in s.split("."): - if piece.isdigit(): - parts.append(int(piece)) - else: - break # prerelease/build suffix — stop, drop the rest - return tuple(parts) - - -def is_newer(latest: tuple, current: tuple) -> bool: - """True iff latest > current (pure tuple comparison).""" - return bool(latest) and latest > current - - -# ── State file (~/.emrg/.last_update_check.json) ────────────────────────── -# {checked_at: float epoch, latest_version: "0.2.18"|None, -# prompted_version: "0.2.18"|None, -# downloaded_version/path/sha: last successful auto-download (rant 12:10:12)} -# - checked_at: last successful check timestamp (TTL gate) -# - latest_version: last known latest from GitHub -# - prompted_version: the version for which a prompt was already shown -# (idempotency: same version is only prompted once) -# - downloaded_*: populated by the background auto-download when a new -# installer was fetched + SHA256-verified into ~/.emrg/updates/ - - -def state_path() -> Path: - return config_dir() / STATE_FILE_NAME - - -def updates_dir() -> Path: - """Landing directory for auto-downloaded installers (~/.emrg/updates/).""" - return config_dir() / UPDATES_DIR_NAME - - -def load_state() -> dict: - try: - data = json.loads(state_path().read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except (OSError, json.JSONDecodeError): - pass - return {} - - -def save_state(state: dict) -> None: - try: - state_path().write_text( - json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8" - ) - except OSError: - pass # state file is best-effort — never crash on write failure - - -def should_check(state: dict, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> bool: - """True iff a check is due (no record, or last check older than TTL).""" - checked_at = state.get("checked_at") - if not isinstance(checked_at, (int, float)): - return True - return (time.time() - checked_at) >= ttl_seconds - - -def should_prompt(state: dict, latest_version: str, current_version: str) -> bool: - """True iff a prompt should be shown for this version (idempotent). - - Conditions: latest is parseable and newer than current, AND this exact - version was not already prompted before. - """ - if not latest_version: - return False - if not is_newer(parse_version(latest_version), parse_version(current_version)): - return False - return state.get("prompted_version") != latest_version - - -def mark_prompted(state: dict, version: str) -> dict: - """Record that a prompt was shown for `version` (mutates + persists).""" - state["prompted_version"] = version - save_state(state) - return state - - -async def check_latest_version(timeout: float = CHECK_TIMEOUT_SECONDS) -> Optional[str]: - """Fetch the latest release tag from api.github.com. - - Returns the tag_name (e.g. '0.2.18') or None on ANY failure — silent, - never raises, never logs noise. The caller retries at the next TTL. - """ - release = await fetch_latest_release(timeout) - if release is None: - return None - tag = release.get("tag_name") or "" - return tag.lstrip("v") if tag else None - - -async def fetch_latest_release(timeout: float = CHECK_TIMEOUT_SECONDS) -> Optional[dict]: - """Fetch the full releases/latest JSON (tag + assets + digests). - - Returns None on ANY failure (silent, never raises). The asset digest - field is used by download_release_asset for SHA256 verification. - """ - try: - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - resp = await client.get(RELEASES_LATEST_URL) - if resp.status_code != 200: - return None - data = resp.json() - return data if isinstance(data, dict) else None - except Exception: - return None - - -async def run_update_check_once(state: Optional[dict] = None) -> dict: - """One deterministic check cycle: fetch latest, persist state, return result. - - Never raises. Used by the daemon background loop and directly testable. - """ - state = state if state is not None else load_state() - latest = await check_latest_version() - if latest is None: - return {"checked": False, "latest_version": None, "state": state} - state["checked_at"] = time.time() - state["latest_version"] = latest - save_state(state) - return {"checked": True, "latest_version": latest, "state": state} - - -# ── Auto-download (rant 2026-08-12T12:10:12) ────────────────────────────── - - -def platform_asset_name(version: str) -> Optional[str]: - """Map the current platform+arch to the make-installer asset name. - - Artifact naming produced by scripts/make-installer (see build-release.yml): - Windows: EMRG--windows-x64.exe - macOS: EMRG--macos-arm64.pkg / -x64.pkg (by machine arch) - Linux: EMRG--linux-x86_64.AppImage / -aarch64.AppImage - Returns None on unsupported platforms — the download is then skipped. - """ - ver = (version or "").lstrip("v") - if not ver: - return None - sysname = platform.system() - machine = (platform.machine() or "").lower() - if sysname == "Windows": - return f"EMRG-{ver}-windows-x64.exe" - if sysname == "Darwin": - arch = "arm64" if machine in ("arm64", "aarch64") else "x64" - return f"EMRG-{ver}-macos-{arch}.pkg" - if sysname == "Linux": - arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64" - return f"EMRG-{ver}-linux-{arch}.AppImage" - return None - - -def release_asset_url(version: str, asset_name: str) -> str: - """Direct download URL for a release asset (no API rate limits).""" - return f"{DOWNLOAD_BASE_URL}/v{(version or '').lstrip('v')}/{asset_name}" - - -def asset_sha256(release_data: dict, asset_name: str) -> Optional[str]: - """Extract the asset digest from the GitHub release JSON, if present. - - GitHub exposes `digest` (e.g. "sha256:ab12…") on release assets. Older - API responses may lack it → return None (caller skips verification and - logs — never blocks the download on a missing digest). - """ - if not isinstance(release_data, dict): - return None - for asset in release_data.get("assets") or []: - if not isinstance(asset, dict): - continue - if asset.get("name") != asset_name: - continue - digest = asset.get("digest") or "" - if digest.startswith("sha256:"): - return digest[len("sha256:"):].strip().lower() - return None # asset found but no usable digest → skip verification - return None # asset not in release metadata (should not happen) - - -def sha256_file(path: Path) -> str: - """Hex SHA256 of a file (streamed, memory-safe).""" - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() - - -async def download_release_asset(version: str, timeout: float = DOWNLOAD_TIMEOUT_SECONDS) -> dict: - """Download the current platform's installer asset into ~/.emrg/updates/. - - Behavior (rant 2026-08-12T12:10:12): - - only the current platform's asset is fetched (platform_asset_name) - - stream + Range header → interrupted downloads resume from the last byte - - SHA256 verified against the release asset digest when available - (digest missing → skip + log, do NOT block); mismatch → delete, retried - at the next TTL - - silent on any failure (never raises) - - Returns a state-update dict on success ({downloaded_version, - downloaded_path, downloaded_sha}) or {} on failure. - """ - asset_name = platform_asset_name(version) - if not asset_name: - return {} - release = await fetch_latest_release() - if release is None: - return {} - digest = asset_sha256(release, asset_name) - - dest_dir = updates_dir() - try: - dest_dir.mkdir(parents=True, exist_ok=True) - except OSError: - return {} - dest = dest_dir / asset_name - part = dest_dir / f"{asset_name}.part" - normalized = (version or "").lstrip("v") - - # Already downloaded + verified → nothing to do. - if dest.exists(): - if digest: - if sha256_file(dest) == digest: - return { - "downloaded_version": normalized, - "downloaded_path": str(dest), - "downloaded_sha": digest, - } - try: - dest.unlink() # tampered → start over - except OSError: - pass - else: - # No digest available — accept the existing file (nothing to - # verify against) and record it. - return { - "downloaded_version": normalized, - "downloaded_path": str(dest), - "downloaded_sha": "", - } - - url = release_asset_url(version, asset_name) - try: - resume_from = part.stat().st_size if part.exists() else 0 - headers = {"Range": f"bytes={resume_from}-"} if resume_from > 0 else {} - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - async with client.stream("GET", url, headers=headers) as resp: - if resp.status_code == 206: - mode = "ab" # partial content → resume appending - elif resp.status_code == 200: - mode = "wb" # server ignored Range → full rewrite - else: - return {} - with open(part, mode) as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - except Exception: - return {} # interrupted — .part kept so the next TTL resumes - - sha = sha256_file(part) - if digest and sha != digest: - # verification failure → delete the partial/tampered file, retry next TTL - try: - part.unlink() - except OSError: - pass - return {} - try: - os.replace(part, dest) - except OSError: - return {} - return { - "downloaded_version": normalized, - "downloaded_path": str(dest), - "downloaded_sha": sha, - } diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 9d373533..f6b47cb1 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1455,46 +1455,6 @@ def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch) assert none_proj["latest_session_at"] == "" -def test_update_check_force_runs_fresh_check(monkeypatch): - """update_check with force:true runs a fresh fetch; without force it - returns the cached state (rant 2026-08-11T09:18:16 manual check button).""" - import asyncio - - server = _make_server() - writer = _FakeWriter() - calls = [] - - async def fake_run_once(state=None): - calls.append(state) - return {"checked": True, "latest_version": "9.9.9", "state": {}} - - monkeypatch.setattr("emrg.update_check.run_update_check_once", fake_run_once) - monkeypatch.setattr( - "emrg.update_check.load_state", - lambda: {"latest_version": "9.9.9", "prompted_version": ""}, - ) - monkeypatch.setattr( - "emrg.update_check.is_newer", - lambda latest, current: latest != current, - ) - - # force:true → fresh check runs, reply carries the refreshed latest - asyncio.run(server._process_message({"type": "update_check", "force": True}, writer)) - assert len(calls) == 1, "force:true must trigger one fresh check" - reply = json.loads(writer._frames[-1]) - assert reply["type"] == "update_check" - assert reply["latest_version"] == "9.9.9" - assert reply["has_update"] is True - - # no force → cached path, no fresh check - writer._frames.clear() - calls.clear() - asyncio.run(server._process_message({"type": "update_check"}, writer)) - assert calls == [], "no force → must return cache without a fresh fetch" - reply = json.loads(writer._frames[-1]) - assert reply["type"] == "update_check" - - # ── rant 2026-08-19T08:05:21:固定端口 bind 排斥 = 唯一单 daemon 准入 ── def test_serve_refuses_duplicate_when_fixed_port_bound(tmp_path): """serve() must exit when another LIVE daemon owns the fixed port. @@ -1656,7 +1616,7 @@ def _make_shutdown_server(tmp_path) -> EmrgServer: server = _make_server() server._stop_reason = "cancel" server._skills_ttl_task = None - server._update_check_task = None + server._upgrade_tick_task = None server._port_keepalive_task = None server._scheduler = AsyncMock() # stop_all + wait_all, no real handlers server._scheduler.stop_all = MagicMock() # real API is sync @@ -1687,7 +1647,7 @@ def test_shutdown_all_logs_reason_and_cleanup_steps(tmp_path, caplog): text = caplog.text assert "daemon stopping (reason=cancel, handlers=0) — cleaning up" in text assert "cancelled skills-ttl loop" in text - assert "cancelled update-check loop" in text + assert "cancelled upgrade-tick loop" in text assert "cancelled port-keepalive loop" in text assert "stopped scheduler" in text assert "closed llm client" in text diff --git a/tests/test_update_check.py b/tests/test_update_check.py deleted file mode 100644 index 217be835..00000000 --- a/tests/test_update_check.py +++ /dev/null @@ -1,560 +0,0 @@ -"""Auto update-check prompt tests (rant 2026-08-10T07:12:12). - -Covers the host-specified acceptance items: -- version comparison (0.2.17 < 0.2.18) -- TTL logic (not expired → no check) -- prompt idempotency (same version not repeated) -- silent failure on network/API errors (no raise, next TTL retries) -- [update] check=false disables the daemon loop -""" - -from __future__ import annotations - -import asyncio -import json -import time -from unittest.mock import AsyncMock, patch - -import pytest - -from emrg.update_check import ( - DEFAULT_TTL_SECONDS, - asset_sha256, - check_latest_version, - download_release_asset, - is_newer, - load_state, - mark_prompted, - parse_version, - platform_asset_name, - release_asset_url, - run_update_check_once, - save_state, - sha256_file, - should_check, - should_prompt, - state_path, -) - - -# ── version comparison ──────────────────────────────────────────────────── - -def test_parse_version_basic(): - assert parse_version("v0.2.18") == (0, 2, 18) - assert parse_version("0.2.17") == (0, 2, 17) - assert parse_version("v1.0") == (1, 0) - assert parse_version("") == () - - -def test_parse_version_drops_prerelease_suffix(): - # releases/latest never returns prereleases, but the parser is defensive: - # a prerelease tag terminates at the non-numeric piece → truncated tuple - # can never compare as NEWER than a released version (safe semantic). - assert parse_version("v0.2.17-beta1") == (0, 2) - assert parse_version("0.2.18-rc.2") == (0, 2) - # prerelease must never trigger a "new version" prompt - assert is_newer(parse_version("0.2.18-rc.2"), parse_version("0.2.17")) is False - - -def test_is_newer(): - assert is_newer((0, 2, 18), (0, 2, 17)) is True - assert is_newer((0, 2, 17), (0, 2, 18)) is False - assert is_newer((0, 2, 17), (0, 2, 17)) is False - assert is_newer((), (0, 2, 17)) is False # unparseable never newer - - -def test_should_prompt_acceptance(): - # 0.2.17 current, latest 0.2.18, not yet prompted → prompt - state = {} - assert should_prompt(state, "0.2.18", "0.2.17") is True - # same version already prompted → no repeat (idempotency) - state = {"prompted_version": "0.2.18"} - assert should_prompt(state, "0.2.18", "0.2.17") is False - # up to date → no prompt - assert should_prompt({}, "0.2.17", "0.2.17") is False - # no latest → no prompt - assert should_prompt({}, "", "0.2.17") is False - - -# ── TTL logic ───────────────────────────────────────────────────────────── - -def test_should_check_missing_state_returns_true(): - assert should_check({}) is True - - -def test_should_check_fresh_state_returns_false(): - state = {"checked_at": time.time()} - assert should_check(state, DEFAULT_TTL_SECONDS) is False - - -def test_should_check_stale_state_returns_true(): - state = {"checked_at": time.time() - DEFAULT_TTL_SECONDS - 1} - assert should_check(state, DEFAULT_TTL_SECONDS) is True - - -def test_should_check_custom_ttl(): - state = {"checked_at": time.time() - 5000} - assert should_check(state, 3600) is True - assert should_check(state, 10000) is False - - -# ── state file ──────────────────────────────────────────────────────────── - -def test_state_roundtrip(tmp_path, monkeypatch): - from emrg import config as config_mod - - monkeypatch.setattr(config_mod, "config_dir", lambda: tmp_path) - # re-read the module-level state_path binding - from emrg import update_check as uc - - monkeypatch.setattr(uc, "state_path", lambda: tmp_path / ".last_update_check.json") - save_state({"checked_at": 123.0, "latest_version": "0.2.18"}) - assert load_state() == {"checked_at": 123.0, "latest_version": "0.2.18"} - - -def test_load_state_missing_file(tmp_path, monkeypatch): - from emrg import update_check as uc - - monkeypatch.setattr(uc, "state_path", lambda: tmp_path / "nope.json") - assert load_state() == {} - - -def test_load_state_corrupt_file(tmp_path, monkeypatch): - from emrg import update_check as uc - - p = tmp_path / "bad.json" - p.write_text("{not json", encoding="utf-8") - monkeypatch.setattr(uc, "state_path", lambda: p) - assert load_state() == {} - - -def test_mark_prompted_persists(tmp_path, monkeypatch): - from emrg import update_check as uc - - p = tmp_path / "state.json" - monkeypatch.setattr(uc, "state_path", lambda: p) - state = {"latest_version": "0.2.18"} - out = mark_prompted(state, "0.2.18") - assert out["prompted_version"] == "0.2.18" - assert json.loads(p.read_text(encoding="utf-8"))["prompted_version"] == "0.2.18" - - -# ── network check ───────────────────────────────────────────────────────── - -def test_check_latest_version_success(): - async def run(): - class _Resp: - status_code = 200 - - def json(self): - return {"tag_name": "v0.2.18"} - - client = AsyncMock() - client.get = AsyncMock(return_value=_Resp()) - client.__aenter__ = AsyncMock(return_value=client) - with patch( - "emrg.update_check.httpx.AsyncClient", - return_value=client, - ): - return await check_latest_version() - - assert asyncio.run(run()) == "0.2.18" - - -def test_check_latest_version_http_error_returns_none(): - async def run(): - class _Resp: - status_code = 500 - - def json(self): - return {} - - client = AsyncMock() - client.get = AsyncMock(return_value=_Resp()) - client.__aenter__ = AsyncMock(return_value=client) - with patch( - "emrg.update_check.httpx.AsyncClient", - return_value=client, - ): - return await check_latest_version() - - assert asyncio.run(run()) is None # silent, no raise - - -def test_check_latest_version_network_error_returns_none(): - async def run(): - with patch( - "emrg.update_check.httpx.AsyncClient", - side_effect=OSError("network unreachable"), - ): - return await check_latest_version() - - assert asyncio.run(run()) is None # silent, no raise - - -def test_run_update_check_once_failure_preserves_state(tmp_path, monkeypatch): - from emrg import update_check as uc - - p = tmp_path / "state.json" - monkeypatch.setattr(uc, "state_path", lambda: p) - with patch.object(uc, "check_latest_version", AsyncMock(return_value=None)): - result = asyncio.run(uc.run_update_check_once({})) - assert result["checked"] is False - assert result["latest_version"] is None - # failure must NOT persist a checked_at (next TTL retries immediately) - assert not p.exists() - - -def test_run_update_check_once_success_persists(tmp_path, monkeypatch): - from emrg import update_check as uc - - p = tmp_path / "state.json" - monkeypatch.setattr(uc, "state_path", lambda: p) - with patch.object(uc, "check_latest_version", AsyncMock(return_value="0.2.18")): - result = asyncio.run(uc.run_update_check_once({})) - assert result["checked"] is True - assert result["latest_version"] == "0.2.18" - saved = json.loads(p.read_text(encoding="utf-8")) - assert saved["latest_version"] == "0.2.18" - assert "checked_at" in saved - - -# ── daemon loop disable (config [update] check=false) ───────────────────── - -def test_update_check_loop_returns_when_disabled(): - """config [update] check=false → loop exits immediately (no network).""" - import emrg.server.daemon as daemon_mod - - async def run(): - server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) - with patch("emrg.config.load_update_config") as mock_cfg: - mock_cfg.return_value.check = False - await server._update_check_loop() - return True - - assert asyncio.run(run()) is True - - -# ── auto-download (rant 2026-08-12T12:10:12) ────────────────────────────── - -def test_platform_asset_name_windows(monkeypatch): - monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Windows") - assert platform_asset_name("v0.2.27") == "EMRG-0.2.27-windows-x64.exe" - - -def test_platform_asset_name_macos_arm64(monkeypatch): - monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Darwin") - monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "arm64") - assert platform_asset_name("0.2.27") == "EMRG-0.2.27-macos-arm64.pkg" - - -def test_platform_asset_name_macos_x64(monkeypatch): - monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Darwin") - monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "x86_64") - assert platform_asset_name("0.2.27") == "EMRG-0.2.27-macos-x64.pkg" - - -def test_platform_asset_name_linux(monkeypatch): - monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Linux") - monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "x86_64") - assert platform_asset_name("0.2.27") == "EMRG-0.2.27-linux-x86_64.AppImage" - monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "aarch64") - assert platform_asset_name("0.2.27") == "EMRG-0.2.27-linux-aarch64.AppImage" - - -def test_platform_asset_name_unsupported(monkeypatch): - monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Plan9") - assert platform_asset_name("0.2.27") is None - assert platform_asset_name("") is None - - -def test_release_asset_url(): - assert release_asset_url("v0.2.27", "EMRG-0.2.27-windows-x64.exe") == ( - "https://github.com/argszero/emrg/releases/download/v0.2.27/" - "EMRG-0.2.27-windows-x64.exe" - ) - - -def test_asset_sha256_extracts_digest(): - release = {"assets": [ - {"name": "EMRG-0.2.27-windows-x64.exe", - "digest": "sha256:ffa9c7cc906e049a61e0a2ff7fd0d8365521d1e225af34de8a9bc022d76c11b7"}, - {"name": "other.txt", "digest": "sha256:beef"}, - ]} - assert asset_sha256(release, "EMRG-0.2.27-windows-x64.exe") == ( - "ffa9c7cc906e049a61e0a2ff7fd0d8365521d1e225af34de8a9bc022d76c11b7" - ) - - -def test_asset_sha256_missing_digest_field(): - # asset found but no digest → None (caller skips verification, never blocks) - release = {"assets": [{"name": "x.pkg"}]} - assert asset_sha256(release, "x.pkg") is None - # asset not in metadata → None - assert asset_sha256(release, "nope.pkg") is None - assert asset_sha256(None, "x.pkg") is None - - -def test_sha256_file(tmp_path): - p = tmp_path / "f.bin" - p.write_bytes(b"hello world") - import hashlib - assert sha256_file(p) == hashlib.sha256(b"hello world").hexdigest() - - -class _FakeStream: - """Async context manager mimicking httpx.Response inside client.stream().""" - - def __init__(self, status_code, chunks): - self.status_code = status_code - self._chunks = chunks - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def aiter_bytes(self): - for c in self._chunks: - yield c - - -_ASSET = "EMRG-0.2.99-windows-x64.exe" # pinned via platform_asset_name patch - - -def _release_json(digest=None, asset_name=_ASSET): - assets = [{"name": asset_name, "digest": f"sha256:{digest}" if digest else None}] - return {"tag_name": "v0.2.99", "assets": assets} - - -def _patch_download(tmp_path, monkeypatch, stream_resp, release=None): - """Wire httpx.AsyncClient mocks so download_release_asset is hermetic. - - Returns (client_mock, capture_dict) — capture["headers"] holds the Range - header the download attempted, for resume assertions. - """ - from unittest.mock import MagicMock - - client = AsyncMock() - client.__aenter__ = AsyncMock(return_value=client) - release = release if release is not None else _release_json(digest="abcd") - client.get = AsyncMock(return_value=_Resp200(release)) - client.stream = MagicMock(return_value=stream_resp) - capture = {} - - orig_stream = client.stream - - def _wrapped_stream(*args, **kwargs): - capture["headers"] = kwargs.get("headers") or {} - return orig_stream(*args, **kwargs) - - client.stream = _wrapped_stream - - monkeypatch.setattr("emrg.update_check.httpx.AsyncClient", lambda *a, **k: client) - monkeypatch.setattr("emrg.update_check.updates_dir", lambda: tmp_path) - # Pin the platform asset name — tests must not depend on the host platform. - monkeypatch.setattr("emrg.update_check.platform_asset_name", lambda v: _ASSET) - return client, capture - - -class _Resp200: - status_code = 200 - - def __init__(self, data): - self._data = data - - def json(self): - return self._data - - -def test_download_success_verify_skipped_when_no_digest(tmp_path, monkeypatch): - from unittest.mock import MagicMock - - client, capture = _patch_download( - tmp_path, monkeypatch, _FakeStream(200, [b"PK\x03\x04", b"DATA"]), - release=_release_json(digest=None), # no digest → skip verify - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert result["downloaded_version"] == "0.2.99" - assert result["downloaded_path"] == str(tmp_path / "EMRG-0.2.99-windows-x64.exe") - assert result["downloaded_sha"] == sha256_file(tmp_path / "EMRG-0.2.99-windows-x64.exe") - # .part consumed → only the final file remains - assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe.part").exists() - assert capture["headers"] == {}, "no Range header on a fresh download" - - -def test_download_verify_failure_deletes_part(tmp_path, monkeypatch): - client, capture = _patch_download( - tmp_path, monkeypatch, _FakeStream(200, [b"tampered-bytes"]), - release=_release_json(digest="0" * 64), # wrong digest - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert result == {}, "verification failure → {} (retry next TTL)" - assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe").exists() - assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe.part").exists() - - -def test_download_resume_sends_range_and_appends(tmp_path, monkeypatch): - part = tmp_path / "EMRG-0.2.99-windows-x64.exe.part" - part.write_bytes(b"0123456789") - client, capture = _patch_download( - tmp_path, monkeypatch, _FakeStream(206, [b"abcdef"]), - release=_release_json(digest=None), - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert capture["headers"] == {"Range": "bytes=10-"}, "resume sends Range from .part size" - final = tmp_path / "EMRG-0.2.99-windows-x64.exe" - assert final.read_bytes() == b"0123456789abcdef", "206 appends to the partial file" - - -def test_download_already_verified_skips_network(tmp_path, monkeypatch): - from unittest.mock import MagicMock - - dest = tmp_path / "EMRG-0.2.99-windows-x64.exe" - dest.write_bytes(b"good-bytes") - digest = sha256_file(dest) - client, capture = _patch_download( - tmp_path, monkeypatch, _FakeStream(200, [b"never-used"]), - release=_release_json(digest=digest), - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert result["downloaded_version"] == "0.2.99" - assert result["downloaded_sha"] == digest - assert capture == {}, "no network call when the file is already verified" - - -def test_download_http_error_silent(tmp_path, monkeypatch): - client, capture = _patch_download( - tmp_path, monkeypatch, _FakeStream(404, [b""]), - release=_release_json(digest=None), - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert result == {} - assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe").exists() - - -def test_download_network_error_silent_keeps_part(tmp_path, monkeypatch): - from unittest.mock import MagicMock - - part = tmp_path / "EMRG-0.2.99-windows-x64.exe.part" - part.write_bytes(b"partial") - - def _boom(*a, **k): - raise OSError("connection reset") - - client = AsyncMock() - client.__aenter__ = AsyncMock(return_value=client) - client.get = AsyncMock(return_value=_Resp200(_release_json(digest=None))) - client.stream = MagicMock(side_effect=_boom) - monkeypatch.setattr("emrg.update_check.httpx.AsyncClient", lambda *a, **k: client) - monkeypatch.setattr("emrg.update_check.updates_dir", lambda: tmp_path) - - result = asyncio.run(download_release_asset("0.2.99")) - assert result == {}, "network failure → {} (silent)" - assert part.exists(), ".part kept so the next TTL resumes" - - -def test_download_unsupported_platform_skips(tmp_path, monkeypatch): - monkeypatch.setattr( - "emrg.update_check.platform_asset_name", lambda v: None - ) - result = asyncio.run(download_release_asset("0.2.99")) - assert result == {} - - -def test_download_release_fetch_failure_silent(tmp_path, monkeypatch): - from unittest.mock import patch as mpatch - - with mpatch( - "emrg.update_check.fetch_latest_release", - AsyncMock(return_value=None), - ): - result = asyncio.run(download_release_asset("0.2.99")) - assert result == {} - - -# ── config defaults (rant 2026-08-12T12:10:12: ttl 24h → 1h + auto_download) ─ - -def test_update_config_defaults(): - from emrg.config import UpdateConfig - - cfg = UpdateConfig() - assert cfg.check is True - assert cfg.ttl_hours == 1, "default TTL 24h → 1h (rant 2026-08-12T12:10:12)" - assert cfg.auto_download is True - - -def test_load_update_config_defaults_missing_file(tmp_path, monkeypatch): - from emrg import config as config_mod - - monkeypatch.setattr(config_mod, "config_path", lambda: tmp_path / "missing.toml") - cfg = config_mod.load_update_config() - assert cfg.ttl_hours == 1 - assert cfg.auto_download is True - - -def test_load_update_config_parses_auto_download(tmp_path, monkeypatch): - from emrg import config as config_mod - - p = tmp_path / "config.toml" - p.write_text("[update]\ncheck = false\nauto_download = false\n", encoding="utf-8") - monkeypatch.setattr(config_mod, "config_path", lambda: p) - cfg = config_mod.load_update_config() - assert cfg.check is False - assert cfg.auto_download is False - assert cfg.ttl_hours == 1, "unset ttl_hours falls back to the new 1h default" - - -# ── daemon: _maybe_auto_download gating (rant 2026-08-12T12:10:12) ───────── - -def test_maybe_auto_download_disabled_by_config(): - import emrg.server.daemon as daemon_mod - - async def run(): - server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) - with patch("emrg.server.daemon.asyncio.create_task") as m_ct: - await server._maybe_auto_download("0.2.99", False) - return m_ct.call_count - - assert asyncio.run(run()) == 0, "auto_download=false → no download task" - - -def test_maybe_auto_download_skips_when_not_newer(): - import emrg.server.daemon as daemon_mod - - async def run(): - server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) - with patch("emrg.server.daemon.asyncio.create_task") as m_ct: - # running version is 0.2.27 (emrg.__version__); "0.2.20" is older - await server._maybe_auto_download("0.2.20", True) - return m_ct.call_count - - assert asyncio.run(run()) == 0 - - -def test_maybe_auto_download_skips_when_already_downloaded(): - import emrg.server.daemon as daemon_mod - - async def run(): - server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) - with patch("emrg.update_check.load_state", return_value={"downloaded_version": "0.2.99"}): - with patch("emrg.server.daemon.asyncio.create_task") as m_ct: - await server._maybe_auto_download("0.2.99", True) - return m_ct.call_count - - assert asyncio.run(run()) == 0, "same version already downloaded → skip" - - -def test_maybe_auto_download_spawns_task_for_newer(): - import emrg.server.daemon as daemon_mod - - async def run(): - server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) - with patch("emrg.update_check.load_state", return_value={}): - with patch.object(server, "_auto_download_update", new=AsyncMock()) as m_dl: - await server._maybe_auto_download("0.2.99", True) - await asyncio.sleep(0) # let the spawned task run - return m_dl.await_count - - assert asyncio.run(run()) == 1, "newer version + not downloaded → spawn task" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py new file mode 100644 index 00000000..ed729694 --- /dev/null +++ b/tests/test_upgrade.py @@ -0,0 +1,340 @@ +"""UpgradeManager tests (rant 2026-08-20T12:33:59 — 自动升级重构). + +Covers the host-specified acceptance items: +- [update] new config fields (enabled/delay_minutes; defaults true/1440) +- tick: delay-filter → newest eligible tag ≠ local version → trigger once +- in-flight guard: no re-trigger while an upgrade session runs +- enabled=false / local==target / network failure → no trigger +- daemon integration: tick fires the session callback, in-flight resets +- no residual references to the removed update_check mechanism +""" + +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path + +import pytest + +from emrg.config import UpdateConfig +from emrg.server.upgrade import ( + RELEASES_URL, + SESSION_ID, + UpgradeManager, + is_newer, + parse_version, +) + +# ── parse_version / is_newer (migrated from update_check.py) ────────────── + + +def test_parse_version_basic(): + assert parse_version("v0.2.18") == (0, 2, 18) + assert parse_version("0.2.18") == (0, 2, 18) + assert parse_version("v0.2.57") == (0, 2, 57) + + +def test_parse_version_prerelease_suffix_stops_parsing(): + # prerelease/build suffixes must never parse as a full version + assert parse_version("v0.2.18-beta1") == (0, 2) + assert parse_version("v0.2.18-rc.2") == (0, 2) + assert parse_version("") == () + assert parse_version("garbage") == () + + +def test_is_newer(): + assert is_newer((0, 2, 57), (0, 2, 56)) + assert not is_newer((0, 2, 56), (0, 2, 57)) + assert not is_newer((0, 2, 18), (0, 2, 18)) + assert not is_newer((), (0, 2, 57)) # unparseable never newer + + +# ── delay filter: takes the NEWEST tag within the eligibility window ────── + + +def _release(tag: str, age_seconds: int) -> dict: + """A release dict published `age_seconds` before now.""" + published = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - age_seconds) + ) + return {"tag_name": tag, "published_at": published} + + +async def _tick_with_releases(monkeypatch, releases, delay_minutes=1440, enabled=True): + """Run one tick with a stubbed releases API response; return trigger calls.""" + calls = [] + + async def fake_run_session(session_id, cwd, prompt): + calls.append({"session_id": session_id, "cwd": cwd, "prompt": prompt}) + + mgr = UpgradeManager( + UpdateConfig(enabled=enabled, delay_minutes=delay_minutes), fake_run_session + ) + + async def fake_get(url): + class _Resp: + status_code = 200 + + def json(self): + return releases + + return _Resp() + + class _FakeClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url): + return await fake_get(url) + + import emrg.server.upgrade as up + + monkeypatch.setattr(up.httpx, "AsyncClient", _FakeClient) + await mgr.tick() + return calls + + +def test_tick_delay_filter_takes_newest_eligible(monkeypatch, tmp_path): + # Two eligible (older than 1 day) + one too-recent (must be excluded) + releases = [ + _release("v0.2.56", 60 * 60 * 24 * 5), + _release("v0.2.57", 60 * 60 * 24 * 2), + _release("v0.2.99", 60 * 5), # too recent — delay window not elapsed + ] + monkeypatch.setattr( + "emrg.server.upgrade.VERSION_FILE", tmp_path / "version.txt" + ) + (tmp_path / "version.txt").write_text("0.2.55\n", encoding="utf-8") + calls = asyncio.run(_tick_with_releases(monkeypatch, releases, delay_minutes=1440)) + assert len(calls) == 1 + assert calls[0]["session_id"] == SESSION_ID + assert "v0.2.57" in calls[0]["prompt"] # newest ELIGIBLE tag (0.2.99 excluded) + + +def test_tick_no_trigger_when_local_matches_target(monkeypatch, tmp_path): + releases = [_release("v0.2.57", 60 * 60 * 24 * 2)] + monkeypatch.setattr( + "emrg.server.upgrade.VERSION_FILE", tmp_path / "version.txt" + ) + (tmp_path / "version.txt").write_text("0.2.57\n", encoding="utf-8") + calls = asyncio.run(_tick_with_releases(monkeypatch, releases)) + assert calls == [], "local == target → no trigger" + + +def test_tick_disabled_by_config(monkeypatch, tmp_path): + releases = [_release("v0.2.57", 60 * 60 * 24 * 2)] + monkeypatch.setattr( + "emrg.server.upgrade.VERSION_FILE", tmp_path / "version.txt" + ) + (tmp_path / "version.txt").write_text("0.2.55\n", encoding="utf-8") + calls = asyncio.run(_tick_with_releases(monkeypatch, releases, enabled=False)) + assert calls == [], "enabled=false → no trigger at all" + + +def test_tick_network_failure_silent(monkeypatch, tmp_path): + monkeypatch.setattr( + "emrg.server.upgrade.VERSION_FILE", tmp_path / "version.txt" + ) + (tmp_path / "version.txt").write_text("0.2.55\n", encoding="utf-8") + + class _FailClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url): + raise Exception("network down") + + import emrg.server.upgrade as up + + monkeypatch.setattr(up.httpx, "AsyncClient", _FailClient) + mgr = UpgradeManager(UpdateConfig(), lambda **kw: asyncio.sleep(0)) + asyncio.run(mgr.tick()) # must not raise + + +def test_inflight_guard_skips_retrigger(monkeypatch, tmp_path): + """While an upgrade session is running, tick() must not re-trigger.""" + releases = [_release("v0.2.57", 60 * 60 * 24 * 2)] + monkeypatch.setattr( + "emrg.server.upgrade.VERSION_FILE", tmp_path / "version.txt" + ) + (tmp_path / "version.txt").write_text("0.2.55\n", encoding="utf-8") + + async def scenario(): + calls = [] + session_done = asyncio.Event() + + async def slow_session(session_id, cwd, prompt): + calls.append(session_id) + await session_done.wait() + + mgr = UpgradeManager(UpdateConfig(), slow_session) + + class _Resp: + status_code = 200 + + def json(self): + return releases + + class _FakeClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url): + return _Resp() + + import emrg.server.upgrade as up + + monkeypatch.setattr(up.httpx, "AsyncClient", _FakeClient) + + # First tick triggers the session (blocked on the event). + t1 = asyncio.create_task(mgr.tick()) + await asyncio.sleep(0.05) + assert mgr._inflight is True, "session start → in-flight" + assert len(calls) == 1 + + # Second tick while in-flight → skipped. + await mgr.tick() + assert len(calls) == 1, "in-flight → no re-trigger" + + # Session finishes → in-flight resets. + session_done.set() + await t1 + assert mgr._inflight is False, "session end → in-flight reset" + + asyncio.run(scenario()) + + +# ── config: new [update] fields ─────────────────────────────────────────── + + +def test_update_config_defaults(): + cfg = UpdateConfig() + assert cfg.enabled is True + assert cfg.delay_minutes == 1440 + assert not hasattr(cfg, "check"), "old [update] check field must be gone" + assert not hasattr(cfg, "ttl_hours"), "old [update] ttl_hours field must be gone" + assert not hasattr(cfg, "auto_download"), "old auto_download field must be gone" + + +def test_load_update_config_new_fields(tmp_path, monkeypatch): + from emrg import config as cfg_mod + + cfg_path = tmp_path / "config.toml" + cfg_path.write_text( + "[update]\nenabled = false\ndelay_minutes = 1\n", encoding="utf-8" + ) + monkeypatch.setattr(cfg_mod, "config_path", lambda: cfg_path) + cfg = cfg_mod.load_update_config() + assert cfg.enabled is False + assert cfg.delay_minutes == 1 + + +def test_load_config_full_new_fields(tmp_path, monkeypatch): + from emrg import config as cfg_mod + + cfg_path = tmp_path / "config.toml" + cfg_path.write_text( + "[llm]\nbase_url = 'x'\napi_key = 'k'\n" + "[update]\nenabled = false\ndelay_minutes = 5\n", + encoding="utf-8", + ) + monkeypatch.setattr(cfg_mod, "config_path", lambda: cfg_path) + cfg = cfg_mod.load_config() + assert cfg.update.enabled is False + assert cfg.update.delay_minutes == 5 + + +# ── daemon integration: tick → run_session_cb (public runner) ───────────── + + +def test_daemon_upgrade_session_runner(monkeypatch, tmp_path): + """The daemon's _run_upgrade_session executes the prompt as an agent + session: session created, busy lock set and released, tool loop invoked.""" + from tests.test_daemon import _make_server # reuse the daemon test helper + + server = _make_server() + monkeypatch.setattr(server, "_max_tool_rounds", 3) + ran = [] + + async def fake_loop(req, ws, session, cancel_event, allow_tools=True): + ran.append((req.session_id, req.cwd, req.prompt, allow_tools)) + # mirror the real _run_tool_loop_locked finally: release the busy lock + server._session_busy[req.session_id] = False + + monkeypatch.setattr(server, "_run_tool_loop_locked", fake_loop) + + asyncio.run(server._run_upgrade_session("emrg-upgrade", str(tmp_path), "PROMPT")) + + assert len(ran) == 1 + assert ran[0][0] == "emrg-upgrade" + assert ran[0][1] == str(tmp_path) + assert ran[0][2] == "PROMPT" + assert ran[0][3] is True, "upgrade sessions run with tools" + assert server._session_busy.get("emrg-upgrade") is False, "busy lock released" + + +# ── no residual references to the removed mechanism ─────────────────────── + + +def test_no_residual_update_check_references(): + """The old update_check mechanism must be fully removed (host §7/§8): + emrg/update_check.py gone; no references to the module, its state file, + or the removed [update] fields outside upgrade.py's own docstring.""" + import subprocess + import sys + from pathlib import Path + + repo = Path(__file__).parent.parent + files = [ + "emrg/server/daemon.py", + "emrg/server/upgrade.py", + "emrg/config.py", + "emrg/client/app.py", + "emrg/gui/main.js", + "emrg/gui/preload.js", + "emrg/gui/renderer/js/app.js", + "emrg/gui/renderer/js/dialogs.js", + "emrg/gui/renderer/js/i18n.js", + "emrg/gui/renderer/index.html", + ] + assert not (repo / "emrg/update_check.py").exists(), "update_check.py must be deleted" + assert not (repo / "tests/test_update_check.py").exists(), "test_update_check.py must be deleted" + for rel in files: + text = (repo / rel).read_text(encoding="utf-8") + # allow the upgrade.py docstring itself + config.py removal note to + # mention the old names; everything else must be clean + if rel == "emrg/server/upgrade.py": + continue + if rel == "emrg/config.py" and "removed" in text: + continue + # daemon.py legitimately keeps the SKILLS updater's run_update_check_once + # (emrg.skills.installer — a separate skills mechanism, not the removed + # auto-upgrade module); strip those lines before asserting. + if rel == "emrg/server/daemon.py": + text = "\n".join( + ln for ln in text.split("\n") if "run_update_check_once" not in ln + ) + assert "update_check" not in text, f"{rel} still references update_check" + assert "ttl_hours" not in text, f"{rel} still references ttl_hours" + assert "auto_download" not in text, f"{rel} still references auto_download" + assert ".last_update_check" not in text, f"{rel} still references the state file"