diff --git a/Agent.md b/Agent.md index dda4aedd..be013708 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` (113: 31 daemon_client + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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` (113: 31 daemon_client + 22 app-commands + 32 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` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 66718827..f604a7d5 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 测试(113 项:31 daemon_client + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(118 项:31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 65852306..2bc5467a 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 (113: 31 daemon_client + 22 app-commands + 32 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 (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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`). diff --git a/emrg/gui/conn-manager.js b/emrg/gui/conn-manager.js new file mode 100644 index 00000000..8bcbbb93 --- /dev/null +++ b/emrg/gui/conn-manager.js @@ -0,0 +1,87 @@ +// conn-manager.js — P2 of the GUI multi-session rant (2026-08-10T15:07:19) +// +// Connection manager = daemon lifecycle unique owner + one DaemonClient per +// open session (each session = one independent websocket connection, aligned +// with the TUI multi-open model). +// +// This slice establishes the open/close/get contract and the daemon-ownership +// bootstrap. main.js rewiring to use this manager (single-session no-regression +// target) lands in a later slice — until then main.js keeps its single client. +// +// Design notes (from the rant): +// - Each open session = one independent ws connection → natural isolation, no +// event routing. +// - connManager ensures the daemon is ready (spawn if missing) before opening; +// session connections then use ensureConnected({ skipStart: true }) — they +// only connect to the already-running daemon, never spawn. +// - resume_session(sid, cwd=projectPath) auto-subscribes the connection. +// - Already-open sid → reuse the existing connection (no duplicate). + +const { DaemonClient } = require("./daemon_client.js"); + +class ConnManager { + constructor({ projectDir, logger = console, isPackaged = false } = {}) { + this.projectDir = projectDir; + this.logger = logger; + this.isPackaged = isPackaged; + this._conns = new Map(); // sid -> { conn, projectPath } + } + + // 确保 daemon 已运行(connManager = daemon 生命周期唯一 owner)。 + // 引导 client ensureConnected():port 文件缺失 → spawn;已运行 → 直连。 + // 连接后立即关闭引导连接——会话连接统一走 skipStart(只连不拉)。 + async _ensureDaemon() { + const boot = new DaemonClient({ + projectDir: this.projectDir, + logger: this.logger, + isPackaged: this.isPackaged, + }); + try { + await boot.ensureConnected(); + } finally { + boot.close(); + } + } + + // open(sid, projectPath):创建 DaemonClient → ensureConnected(skipStart) → + // resume_session(自动订阅)。已打开的 sid 直接复用现有连接。 + async open(sid, projectPath) { + const existing = this._conns.get(sid); + if (existing) return existing.conn; + await this._ensureDaemon(); + const conn = new DaemonClient({ + projectDir: this.projectDir, + logger: this.logger, + isPackaged: this.isPackaged, + }); + await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉 + await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000); + this._conns.set(sid, { conn, projectPath }); + return conn; + } + + // close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。 + close(sid) { + const entry = this._conns.get(sid); + if (!entry) return false; + entry.conn.close(); + this._conns.delete(sid); + return true; + } + + // get(sid):路由到对应实例;未打开返回 null。 + get(sid) { + const entry = this._conns.get(sid); + return entry ? entry.conn : null; + } + + all() { + return [...this._conns.keys()]; + } + + closeAll() { + for (const sid of [...this._conns.keys()]) this.close(sid); + } +} + +module.exports = { ConnManager }; diff --git a/emrg/gui/test/conn-manager.test.js b/emrg/gui/test/conn-manager.test.js new file mode 100644 index 00000000..89b71627 --- /dev/null +++ b/emrg/gui/test/conn-manager.test.js @@ -0,0 +1,177 @@ +// conn-manager.test.js — P2 (rant 2026-08-10T15:07:19) connection manager contract +// open/close/get: daemon bootstrap + skipStart session connections + resume_session. + +"use strict"; + +const assert = require("node:assert"); +const { test, beforeEach, afterEach } = require("node:test"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const Module = require("node:module"); + +// ── MockWs(与 daemon_client.test.js 同款)────────────────────────────── +let currentMockWs = null; + +class MockWs { + constructor(url) { + this.url = url; + this.sent = []; + this._listeners = {}; + currentMockWs = this; + } + on(ev, cb) { (this._listeners[ev] = this._listeners[ev] || []).push(cb); return this; } + once(ev, cb) { + const wrap = (...a) => { this.off(ev, wrap); cb(...a); }; + wrap._orig = cb; + return this.on(ev, wrap); + } + off(ev, cb) { + if (!this._listeners[ev]) return this; + this._listeners[ev] = this._listeners[ev].filter((l) => l !== cb && l._orig !== cb); + return this; + } + emit(ev, ...a) { for (const cb of [...(this._listeners[ev] || [])]) cb(...a); return this; } + send(data) { this.sent.push(typeof data === "string" ? data : JSON.stringify(data)); } + close() { this.emit("close"); } + destroy() { this.emit("close"); } +} + +const origLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === "ws") return MockWs; + return origLoad.apply(this, arguments); +}; + +const { ConnManager } = require("../conn-manager.js"); +const { DaemonClient } = require("../daemon_client.js"); + +let tmpHome = null; +let origHome = null; +let origUserProfile = null; + +function setupTempHome() { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "emrg-conn-test-")); + fs.mkdirSync(path.join(tmpHome, ".emrg"), { recursive: true }); + origHome = process.env.HOME; + origUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + // 预写 port 文件(模拟已运行 daemon)——路径必须落在 tmpHome 内 + const portFile = path.join(tmpHome, ".emrg", "emrgd.port"); + assert.ok( + path.resolve(portFile).startsWith(path.resolve(tmpHome) + path.sep), + "port file escapes tmpHome", + ); + fs.writeFileSync(portFile, "41234\nseekrit-token"); +} + +function teardownTempHome() { + if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME; + if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE; + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = null; +} + +async function waitForWs(predicate = () => currentMockWs) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (predicate()) return currentMockWs; + await new Promise((r) => setTimeout(r, 5)); + } + assert.ok(false, "ws not created within timeout"); +} + +async function waitForAuthSent(ws) { + const deadline = Date.now() + 2000; + while (ws.sent.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5)); + } + assert.ok(ws.sent.length > 0, "auth frame should be sent"); +} + +/** 驱动一条连接完成 auth(open → auth 帧 → auth_ok) */ +async function driveAuth(ws) { + ws.emit("open"); + await waitForAuthSent(ws); + const authFrame = JSON.parse(ws.sent[0]); + assert.strictEqual(authFrame.type, "auth"); + ws.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); +} + +/** 驱动 manager.open 的完整两段流程:引导连接 + 会话连接(含 resume_session 响应) */ +async function driveOpen(manager, sid, projectPath) { + const p = manager.open(sid, projectPath); + // ① 引导 client:连接 → auth → 关闭 + let bootWs = await waitForWs(); + await driveAuth(bootWs); + // 等引导 close(ensureConnected resolve → close 触发 ws.close → 新连接可能已建) + await new Promise((r) => setTimeout(r, 10)); + // ② 会话 client:连接 → auth → resume_session → resume_result + let sessionWs = await waitForWs(() => currentMockWs !== bootWs); + await driveAuth(sessionWs); + // 等 resume_session 帧发出 + const deadline = Date.now() + 2000; + while (sessionWs.sent.length < 2 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5)); + } + const resumeFrame = JSON.parse(sessionWs.sent.at(-1)); + assert.strictEqual(resumeFrame.type, "resume_session", "session open must resume_session"); + assert.strictEqual(resumeFrame.session_id, sid); + assert.strictEqual(resumeFrame.cwd, projectPath); + sessionWs.emit("message", Buffer.from(JSON.stringify({ type: "resume_result", session_id: sid }))); + const conn = await p; + return { conn, sessionWs }; +} + +beforeEach(() => { + setupTempHome(); + currentMockWs = null; +}); +afterEach(() => { + teardownTempHome(); + currentMockWs = null; +}); + +test("P2 open: 引导 daemon + skipStart 会话连接 + resume_session 自动订阅", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const { conn, sessionWs } = await driveOpen(manager, "sess-1", "/proj/a"); + + assert.ok(conn instanceof DaemonClient); + assert.strictEqual(conn.connected, true); + assert.strictEqual(manager.get("sess-1"), conn); + assert.deepStrictEqual(manager.all(), ["sess-1"]); + // 会话连接 url = 预写 port 文件端口(daemon 未重启) + assert.strictEqual(sessionWs.url, "ws://127.0.0.1:41234"); +}); + +test("P2 open: 已打开 sid → 复用连接,不重复 resume_session", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const first = await driveOpen(manager, "sess-1", "/proj/a"); + const sentBefore = first.sessionWs.sent.length; + const second = await manager.open("sess-1", "/proj/a"); + assert.strictEqual(second, first.conn, "same sid must reuse the same conn"); + assert.strictEqual(first.sessionWs.sent.length, sentBefore, "no second resume_session"); +}); + +test("P2 get: 未打开 sid → null", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + assert.strictEqual(manager.get("nope"), null); +}); + +test("P2 close: 断开并移除;再 get → null;重复 close → false", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const { conn } = await driveOpen(manager, "sess-1", "/proj/a"); + assert.strictEqual(manager.close("sess-1"), true); + assert.strictEqual(manager.get("sess-1"), null); + assert.strictEqual(conn.connected, false, "close must disconnect the ws"); + assert.strictEqual(manager.close("sess-1"), false, "second close returns false"); +}); + +test("P2 closeAll: 全部关闭", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + await driveOpen(manager, "sess-1", "/proj/a"); + await driveOpen(manager, "sess-2", "/proj/b"); + manager.closeAll(); + assert.strictEqual(manager.all().length, 0); +});