From 1213ebd56aae7edea71402cefdf61d8e09c57fc9 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 17:52:21 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20GUI=20open-sessions=20state=20+=20g?= =?UTF-8?q?ui=5Fstate.json=20persistence=20=E2=80=94=20P4=20slice=201=20(G?= =?UTF-8?q?UI=20multi-session=20rant=20P4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/conn-manager.js | 2 +- emrg/gui/gui-state.js | 46 +++++++++++++ emrg/gui/main.js | 114 ++++++++++++++++++++++++++++++-- emrg/gui/preload.js | 2 + emrg/gui/test/gui-state.test.js | 84 +++++++++++++++++++++++ 5 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 emrg/gui/gui-state.js create mode 100644 emrg/gui/test/gui-state.test.js diff --git a/emrg/gui/conn-manager.js b/emrg/gui/conn-manager.js index e01af96..469f94b 100644 --- a/emrg/gui/conn-manager.js +++ b/emrg/gui/conn-manager.js @@ -103,7 +103,7 @@ class ConnManager { }); this._conns.set(sid, { conn, projectPath }); for (const cb of this._openHooks) { - try { cb(sid, conn); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); } + try { cb(sid, conn, projectPath); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); } } return conn; } diff --git a/emrg/gui/gui-state.js b/emrg/gui/gui-state.js new file mode 100644 index 0000000..0b27037 --- /dev/null +++ b/emrg/gui/gui-state.js @@ -0,0 +1,46 @@ +// gui-state.js — P4 of the GUI multi-session rant (2026-08-10T15:07:19) +// +// gui_state.json persistence: { openSessions: [{sid, projectName, projectPath, +// lastActive}], activeSid }. +// +// This slice (P4 slice 1) covers the WRITE path (main.js keeps open sessions +// alive across switches, persists them, and exposes close/get IPC). The READ +// path (restore on boot + sidebar UI) lands in P4 slice 2. +// +// Design notes (from the rant): +// - Open sessions = cross-project tabs (aligned with the TUI multi-open model). +// - Cap 20: never persist more than the cap; restore (slice 2) takes the most +// recent 20 by lastActive. +// - Write is atomic (.tmp + rename) so a crash mid-write never corrupts state. +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_CAP = 20; + +function guiStatePath(homeDir) { + return path.join(homeDir, ".emrg", "gui_state.json"); +} + +// 清洗 openSessions:跳过失效条目(缺 sid/projectPath),按 lastActive 倒序, +// 截断到 cap(默认 20)。写盘前与恢复(slice 2)共用同一规则。 +function sanitizeOpenSessions(list, cap = DEFAULT_CAP) { + const valid = (list || []).filter( + (s) => s && typeof s.sid === "string" && s.sid && typeof s.projectPath === "string" && s.projectPath + ); + valid.sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); + return valid.slice(0, cap); +} + +// 原子写:tmp + rename(镜像 install-info.json 原子写 #569 模式)。 +// 目录缺失 → 创建。写失败 → 抛(调用方捕获并记录,不阻断主流程)。 +function saveGuiState(homeDir, state) { + const p = guiStatePath(homeDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = p + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, p); +} + +module.exports = { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP }; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 06ba355..f36f64e 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -13,6 +13,7 @@ const { spawn } = require("child_process"); const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); const { generateSessionId, SESSION_ID_RE } = require("./daemon_client"); const { ConnManager } = require("./conn-manager"); +const { guiStatePath, sanitizeOpenSessions, saveGuiState } = require("./gui-state"); const APP_VERSION = require("./package.json").version; // ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)── @@ -39,6 +40,11 @@ function main() { // (对称 TUI app.py _throttle_warned,PR #594)。成功连接后复位。 let daemonStoppedNotified = false; let stopping = false; + // P4(rant 15:07:19):跨项目打开的会话状态——sid → {projectName, projectPath, + // lastActive}。写盘防抖 1s(打开/关闭/切换时更新,镜像 rant 设计)。 + let openSessions = new Map(); + let guiStateTimer = null; + const GUI_STATE_DEBOUNCE_MS = 1000; // ── 窗口 ──────────────────────────────────────────────── @@ -269,6 +275,7 @@ vision = false if (!conn || !conn.connected) { conn = await openSession(sessionId, projectDir, { resume: false }); } + markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化 let rid; try { // G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口) @@ -306,8 +313,9 @@ vision = false } currentSessionId = sessionId; win.setTitle(`EMRG — ${sessionId}`); // G109 - // 单会话模型:切走即关闭旧会话连接(每会话一条连接;多会话 openSessions 由 P4 管理) - if (prevSid && prevSid !== sessionId) connManager?.close(prevSid); + // P4(rant 15:07:19):多会话保持——切走**不再关闭**旧会话连接(浏览器 tab + // 效果:切回继续生成/看现场)。关闭走 emrg:closeSession(P4 slice 2 侧边栏)。 + markSessionActive(sessionId); // 更新 lastActive + activeSid 防抖写盘 return {}; }); @@ -315,6 +323,8 @@ vision = false if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: projectDir }, 5000); connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开) + openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记 + schedulePersistGuiState(); return { ok: true }; }); @@ -326,12 +336,28 @@ vision = false return { ok: true, title: frame.title || clean }; }); + // P4(rant 15:07:19):关闭会话 = 断开连接 + 移除 + 持久化,**保留磁盘数据** + // (与 delete_session 区分:关闭留数据 / 删除删数据)。 + ipcMain.handle("emrg:closeSession", async (_e, { sessionId }) => { + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + return closeSession(sessionId); + }); + + // P4:跨项目打开会话列表(侧边栏数据源,slice 2 消费) + ipcMain.handle("emrg:getOpenSessions", async () => ({ + openSessions: openSessionsList(), + activeSid: currentSessionId, + })); + ipcMain.handle("emrg:newSession", async () => { // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); // 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109) currentSessionId = sid; win.setTitle(`EMRG — ${sid}`); + // P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活 + // 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记) + schedulePersistGuiState(); return { session_id: sid }; }); @@ -625,8 +651,10 @@ vision = false if (connManager) return connManager; connManager = new ConnManager({ projectDir, logger, isPackaged: app.isPackaged }); // 每个新会话连接建立时挂 renderer 事件桥(附带 sid;含 recoverAll 重开路径) - connManager.onOpen((sid, conn) => { + connManager.onOpen((sid, conn, projectPath) => { conn.onEvent((type, data) => { + // P4:消息活动刷新该会话 lastActive(激活/发送/done 更新,写盘防抖 1s) + if (type === "done" || type === "message_delta") touchOpenSession(sid, projectPath); if (win && !win.isDestroyed()) { // 主动关闭(切走/删除)不触发断线横幅——真断连/daemon 重启照常转发 if (type === "disconnected" && conn._intentionalClose) return; @@ -669,8 +697,83 @@ vision = false // 打开(或复用)会话连接;事件桥由 onOpen 钩子统一挂(含 recoverAll 重开)。 async function openSession(sid, projectPath, { resume = true } = {}) { const existing = connManager.get(sid); - if (existing && existing.connected) return existing; - return connManager.open(sid, projectPath, { resume }); + if (existing && existing.connected) { + touchOpenSession(sid, projectPath); // P4:复用连接也刷新打开会话状态 + return existing; + } + const conn = await connManager.open(sid, projectPath, { resume }); + touchOpenSession(sid, projectPath); + return conn; + } + + // ── P4 openSessions 簿记 + gui_state.json 持久化 ────────────────── + + function projectNameOf(projectPath) { + return path.basename(projectPath) || projectPath; + } + + // 记录/刷新一个打开会话(复用连接、恢复重开、recoverAll 均刷新) + function touchOpenSession(sid, projectPath) { + openSessions.set(sid, { + projectName: projectNameOf(projectPath), + projectPath, + lastActive: new Date().toISOString(), + }); + } + + // 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘 + function markSessionActive(sid) { + if (sid && openSessions.has(sid)) { + openSessions.get(sid).lastActive = new Date().toISOString(); + } + schedulePersistGuiState(); + } + + function schedulePersistGuiState() { + if (guiStateTimer) clearTimeout(guiStateTimer); + guiStateTimer = setTimeout(() => { + guiStateTimer = null; + persistGuiStateNow(); + }, GUI_STATE_DEBOUNCE_MS); + guiStateTimer.unref?.(); + } + + function persistGuiStateNow() { + try { + const entries = [...openSessions.entries()].map(([sid, v]) => ({ + sid, + projectName: v.projectName, + projectPath: v.projectPath, + lastActive: v.lastActive, + })); + const state = { + openSessions: sanitizeOpenSessions(entries), // 写盘侧也守上限 20 + activeSid: currentSessionId, + }; + saveGuiState(os.homedir(), state); + } catch (e) { + logger.warn(`[gui] gui_state.json persist failed: ${e.message}`); + } + } + + // 关闭会话(保留磁盘数据):断连 + 移除 + 持久化。P4 slice 2 侧边栏"关闭"入口用。 + async function closeSession(sid) { + const entry = openSessions.get(sid); + connManager?.close(sid); // 主动关闭(_intentionalClose 抑制断线横幅) + openSessions.delete(sid); + schedulePersistGuiState(); + if (currentSessionId === sid) currentSessionId = null; // 关闭激活会话 → 无激活 + return { ok: true, closed: !!entry }; + } + + function openSessionsList() { + return [...openSessions.entries()] + .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive })) + .sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); + } + + function guiStateFilePath() { + return guiStatePath(os.homedir()); } async function ensureConnected() { @@ -814,6 +917,7 @@ vision = false app.on("window-all-closed", () => { stopping = true; cancelReconnect(); + persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消) connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接 if (process.platform !== "darwin") app.quit(); }); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 3a076d9..14cafe1 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -13,6 +13,8 @@ const api = { switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), newSession: () => ipcRenderer.invoke("emrg:newSession"), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), + closeSession: (payload) => ipcRenderer.invoke("emrg:closeSession", payload), + getOpenSessions: () => ipcRenderer.invoke("emrg:getOpenSessions"), renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload), setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload), clearSession: (payload) => ipcRenderer.invoke("emrg:clearSession", payload), diff --git a/emrg/gui/test/gui-state.test.js b/emrg/gui/test/gui-state.test.js new file mode 100644 index 0000000..c70d1b7 --- /dev/null +++ b/emrg/gui/test/gui-state.test.js @@ -0,0 +1,84 @@ +// gui-state.test.js — P4 slice 1(rant 2026-08-10T15:07:19) +// gui_state.json 持久化模块:路径、清洗(上限 20 + 失效条目跳过 + lastActive 倒序)、原子写。 +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("../gui-state.js"); + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "emrg-gui-state-")); +} + +test("gui-state: 路径固定在 /.emrg/gui_state.json", () => { + const h = tmpHome(); + assert.strictEqual(guiStatePath(h), path.join(h, ".emrg", "gui_state.json")); +}); + +test("gui-state: sanitize 跳过失效条目(缺 sid/projectPath)", () => { + const list = [ + { sid: "s1", projectPath: "/a", lastActive: "2026-08-10T10:00:00" }, + { projectPath: "/b" }, // 缺 sid + { sid: "s2" }, // 缺 projectPath + { sid: "", projectPath: "/c" }, // 空 sid + null, + "junk", + { sid: "s3", projectPath: "/d", lastActive: "2026-08-10T09:00:00" }, + ]; + const out = sanitizeOpenSessions(list); + assert.deepStrictEqual(out.map((s) => s.sid), ["s1", "s3"], "invalid entries dropped, order preserved"); +}); + +test("gui-state: sanitize 按 lastActive 倒序 + 上限 20", () => { + const list = Array.from({ length: 25 }, (_, i) => ({ + sid: `s${i}`, + projectPath: `/p/${i}`, + lastActive: `2026-08-10T${String(i).padStart(2, "0")}:00:00`, + })); + const out = sanitizeOpenSessions(list); + assert.strictEqual(out.length, DEFAULT_CAP, "capped at 20"); + assert.strictEqual(out[0].sid, "s24", "most recent first (lastActive desc)"); + assert.strictEqual(out[19].sid, "s5", "oldest kept is the 20th"); +}); + +test("gui-state: sanitize 缺失 lastActive 视作最旧(字符串比较稳定)", () => { + const out = sanitizeOpenSessions([ + { sid: "no-ts", projectPath: "/x" }, + { sid: "new", projectPath: "/y", lastActive: "2026-08-10T12:00:00" }, + ]); + assert.strictEqual(out[0].sid, "new"); + assert.strictEqual(out[1].sid, "no-ts"); +}); + +test("gui-state: saveGuiState 原子写 + 目录自动创建", () => { + const h = tmpHome(); + const state = { openSessions: [{ sid: "s1", projectName: "a", projectPath: "/a", lastActive: "t" }], activeSid: "s1" }; + saveGuiState(h, state); + const p = guiStatePath(h); + assert.ok(fs.existsSync(p), "file written"); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(p, "utf8")), state, "content round-trips"); + assert.ok(!fs.existsSync(p + ".tmp"), "no tmp residue after rename"); +}); + +test("gui-state: saveGuiState 覆盖旧内容(原子替换)", () => { + const h = tmpHome(); + saveGuiState(h, { openSessions: [], activeSid: null }); + saveGuiState(h, { openSessions: [{ sid: "x", projectName: "p", projectPath: "/p", lastActive: "t" }], activeSid: "x" }); + const parsed = JSON.parse(fs.readFileSync(guiStatePath(h), "utf8")); + assert.strictEqual(parsed.openSessions.length, 1); + assert.strictEqual(parsed.activeSid, "x"); +}); + +test("gui-state: 写盘侧上限——persist 前先 sanitize(主流程调用方模式)", () => { + const entries = Array.from({ length: 30 }, (_, i) => ({ + sid: `s${i}`, + projectName: `p${i}`, + projectPath: `/p/${i}`, + lastActive: `2026-08-10T${String(59 - i).padStart(2, "0")}:00:00`, // s0 最新(59 > 58 > …) + })); + const out = sanitizeOpenSessions(entries); + assert.strictEqual(out.length, DEFAULT_CAP, "never persists more than 20"); + assert.strictEqual(out[0].sid, "s0", "most recent first"); + assert.strictEqual(out[19].sid, "s19", "20th kept"); +}); From 6dfd4ae8c32df3fe4c73173217d6e405001c4db4 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 17:53:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20doc=20counts=20153=E2=86=92160=20(g?= =?UTF-8?q?ui-state=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 ++-- README.cn.md | 2 +- README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Agent.md b/Agent.md index 493ee93..7b67e7f 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (160: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (160: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.cn.md b/README.cn.md index 8a9fc2b..bace5d4 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(153 项:43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(160 项:43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index babce42..5f76ce5 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (160: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`).