From 096193a8ec096800a0c04080efb2c5252a738e99 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:11:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E9=87=87=E7=BA=B3=20sidecar?= =?UTF-8?q?=20=E6=96=B9=E6=A1=88=EF=BC=88=E9=80=89=E9=A1=B9=20D=20?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=89=E2=80=94=E2=80=94=20Node?= =?UTF-8?q?=20sidecar=20=E5=AE=9E=E7=8E=B0=20+=208/8=20=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移** (改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为 「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。 ## 本次交付 1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成 **JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state; onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event) 2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的 ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报 "Dynamic require of events is not supported" 3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本 4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向) ## 两条协议纪律(写进注释与文档) - **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败) - 任何异常都转成响应,**绝不因单个请求崩进程** ## 错误形态归一(实测中修正) 被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。 若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`; 而「未连接/缺参数」仍走协议错误(语义不同)。 ## 验证(真实链路,非 mock) 本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ · invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ · 非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓ 打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。 ## 待办(下一批) Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 + Node 运行时打包 · 退役 crates/nodeagent-client --- apps/console/package.json | 8 +- apps/console/sidecar/build.mjs | 52 ++++++++ apps/console/sidecar/main.mjs | 210 +++++++++++++++++++++++++++++++++ docs/console-plan.md | 73 ++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 apps/console/sidecar/build.mjs create mode 100644 apps/console/sidecar/main.mjs diff --git a/apps/console/package.json b/apps/console/package.json index 6c72926..0d94e93 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "sidecar:dev": "node sidecar/main.mjs", + "sidecar:build": "node sidecar/build.mjs" }, "dependencies": { "@tauri-apps/api": "^2", @@ -16,7 +18,9 @@ "naive-ui": "^2.45.3", "pinia": "^4.0.3", "vue": "^3.5.13", - "vue-router": "^5.3.1" + "vue-router": "^5.3.1", + "@nodeagent/client": "workspace:*", + "@nodeagent/protocol": "workspace:*" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/apps/console/sidecar/build.mjs b/apps/console/sidecar/build.mjs new file mode 100644 index 0000000..4807856 --- /dev/null +++ b/apps/console/sidecar/build.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。 + * + * 用法:node apps/console/sidecar/build.mjs [--out ] + * + * 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`: + * pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。 + * 用 API 则从模块解析走,与包管理器布局无关。 + * + * 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包, + * **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。 + * `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。 + */ + +import { build } from 'esbuild'; +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const outIdx = argv.indexOf('--out'); +const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs'); + +mkdirSync(dirname(outFile), { recursive: true }); + +const result = await build({ + entryPoints: [join(here, 'main.mjs')], + outfile: outFile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + // Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联 + external: ['node:*'], + // ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后 + // 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim + // 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。 + banner: { + js: + '// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' + + "import { createRequire as __naCreateRequire } from 'node:module';\n" + + 'const require = __naCreateRequire(import.meta.url);', + }, + legalComments: 'none', + minify: false, // 保持可读:出问题时能直接看堆栈 + metafile: true, +}); + +const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0; +console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`); diff --git a/apps/console/sidecar/main.mjs b/apps/console/sidecar/main.mjs new file mode 100644 index 0000000..76b5d05 --- /dev/null +++ b/apps/console/sidecar/main.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。 + * + * ## 为什么需要它(选项 D:Tauri + Node sidecar) + * console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」—— + * 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为 + * **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。 + * + * ## 协议(行分隔 JSON,每条消息一行) + * 请求(Rust → 本进程): + * {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}} + * {"jsonrpc":"2.0","id":2,"method":"disconnect"} + * {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}} + * {"jsonrpc":"2.0","id":4,"method":"state"} + * 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}} + * 通知(无 id,本进程主动推): + * {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}} + * {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}} + * {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}} + * + * ## 纪律 + * - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。 + * - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。 + */ + +import { createInterface } from 'node:readline'; +import { NodeAgentClient } from '@nodeagent/client'; + +/** @type {NodeAgentClient | null} */ +let client = null; +/** 最近一次连接参数(重连/状态查询用)。 */ +let lastOptions = null; + +// ---------------- 输出(唯一写 stdout 的地方) ---------------- + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} + +function ok(id, result) { + send({ jsonrpc: '2.0', id, result: result ?? null }); +} + +function fail(id, code, message, data) { + send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }); +} + +function notify(kind, payload) { + send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } }); +} + +/** 日志走 stderr(stdout 归协议专用)。 */ +function log(level, message) { + process.stderr.write(`[sidecar] ${level} ${message}\n`); +} + +// ---------------- 状态快照 ---------------- + +function stateSnapshot() { + if (!client) return { connected: false }; + return { + connected: true, + capabilities: client.listCapabilities(), + authorized: client.listAuthorized(), + agent_version: client.getAgentVersion() ?? null, + agent_build: client.getAgentBuild() ?? null, + peer_cert_fp: client.getPeerCertFingerprint(), + client_id: lastOptions?.clientId ?? null, + auth_mode: lastOptions?.authMode ?? 'psk', + }; +} + +// ---------------- 方法实现 ---------------- + +const methods = { + /** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */ + async connect(params) { + if (!params?.url || !params?.key) { + throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 }); + } + // 已有连接先干净关闭,避免两条 ws 并存 + if (client) { + try { + client.close(); + } catch { + /* 关不掉也无妨,下面会覆盖 */ + } + } + lastOptions = { + url: String(params.url), + clientId: String(params.client_id ?? 'console'), + authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk', + }; + + client = new NodeAgentClient({ + url: lastOptions.url, + key: String(params.key), + clientId: lastOptions.clientId, + insecure: params.insecure !== false, + ...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}), + ...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}), + ...(params.private_key ? { privateKey: String(params.private_key) } : {}), + ...(params.hub ? { hub: params.hub } : {}), + autoReconnect: params.auto_reconnect !== false, + + // 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event) + onStateChange: (state) => notify('state', { state }), + onLog: (message) => notify('log', { message }), + onEvent: (event) => notify('agent_event', { event }), + }); + + await client.connect(); + log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`); + return stateSnapshot(); + }, + + disconnect() { + if (client) { + try { + client.close(); + } catch (err) { + log('warn', `关闭连接时异常:${String(err)}`); + } + client = null; + } + return { connected: false }; + }, + + async invoke(params) { + // 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code + if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 }); + if (!params?.capability) { + throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 }); + } + try { + // 正常路径:被控端返回 InvokeResult(status: ok|failed) + return await client.invoke( + String(params.capability), + (params.args ?? {}), + typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined, + ); + } catch (err) { + // ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。 + // 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。 + // 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。 + return { + status: 'failed', + error: { + name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED', + message: err instanceof Error ? err.message : String(err), + ...(err?.data !== undefined ? { data: err.data } : {}), + }, + }; + } + }, + + state: () => stateSnapshot(), +}; + +// ---------------- 主循环 ---------------- + +const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on('line', (line) => { + const text = line.trim(); + if (!text) return; + + let req; + try { + req = JSON.parse(text); + } catch { + fail(null, -32700, 'JSON 解析失败'); + return; + } + + const { id = null, method, params } = req ?? {}; + const fn = methods[method]; + if (typeof fn !== 'function') { + fail(id, -32601, `未知方法: ${String(method)}`); + return; + } + + // 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程 + Promise.resolve() + .then(() => fn(params ?? {})) + .then((result) => ok(id, result)) + .catch((err) => { + const code = typeof err?.code === 'number' ? err.code : -32603; + const msg = err instanceof Error ? err.message : String(err); + log('error', `${String(method)} 失败:${msg}`); + fail(id, code, msg, err?.data); + }); +}); + +rl.on('close', () => { + // Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出 + methods.disconnect(); + log('info', 'stdin 关闭,sidecar 退出'); + process.exit(0); +}); + +process.on('uncaughtException', (err) => { + log('error', `未捕获异常:${err?.stack ?? String(err)}`); +}); +process.on('unhandledRejection', (err) => { + log('error', `未处理的 rejection:${String(err)}`); +}); + +log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`); diff --git a/docs/console-plan.md b/docs/console-plan.md index 5d46cea..39d1094 100644 --- a/docs/console-plan.md +++ b/docs/console-plan.md @@ -319,3 +319,76 @@ cargo run --example probe -- ws://127.0.0.1:8765 /tmp/nodeagent-e2e | C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 | > 仅供参考,本方案为技术规划,不构成任何投资建议。 + +--- + +## 实施决策(2026-10-10):采纳「Tauri + Node sidecar」,退役 Rust client + +### 为什么改 + +原计划(本文档 §2)让 Rust 壳**自己实现一份 client**(`crates/nodeagent-client`), +代价是「**两套协议实现**」—— Rust 版与 TS 版会**静默漂移**:改了一处漏另一处时, +构建通过、单测通过,只在真机运行时才暴露。这是本项目已经反复吃过的一类坑 +(见 `RETROSPECTIVE.md` 的「两份真相」教训)。 + +**sidecar 方案**让 Rust 壳退化为**进程管理者 + 消息转发者**,协议实现只剩 TS 一份: + +``` +┌──────────────┐ Tauri command(签名不变) ┌─────────────────┐ +│ Vue 前端 │ ───────────────────────────► │ Rust 壳 │ +└──────────────┘ ◄──── Tauri event ────────── │(启动/转发/监督) │ + └────────┬────────┘ + stdin/stdout(JSON-RPC 2.0,行分隔) + ▼ + ┌───────────────────────────┐ + │ Node sidecar(sidecar.mjs)│ + │ 复用 @nodeagent/client │ + └───────────────────────────┘ +``` + +### sidecar 协议 + +请求(Rust → sidecar): +```jsonc +{"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,auth_mode?,private_key?,cert_sha256?,hub?}} +{"jsonrpc":"2.0","id":2,"method":"invoke","params":{capability,args,timeout_ms?}} +{"jsonrpc":"2.0","id":3,"method":"state"} +{"jsonrpc":"2.0","id":4,"method":"disconnect"} +``` +通知(sidecar → Rust,用 `params.kind` 区分,Rust 侧再桥接成 Tauri event): +```jsonc +{"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}} +{"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}} +{"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{…}}} +``` + +**两条纪律**:① **stdout 只写协议消息**(日志一律 stderr,混入非 JSON 行会让 Rust 侧解析失败); +② 任何异常都转成响应,**绝不因单个请求崩进程**。 + +### 错误形态归一(真机实测中修正) + +被控端对「未知能力 / ACL 拒绝 / 超时」是回 **JSON-RPC error**,`client.invoke()` 因此**抛异常**。 +若原样上抛,前端要处理两种形态。sidecar 已把它归一成 `InvokeResult` 的 failed 形态: + +```jsonc +{"result":{"status":"failed","error":{"name":"E_CAPABILITY_NOT_FOUND","message":"…"}}} +``` +「调用方式不对」(未连接 / 缺参数)仍走**协议错误**(带明确 code),二者语义不同、不混淆。 + +### 进度 + +| 步骤 | 状态 | +|---|---| +| ① sidecar 实现(`apps/console/sidecar/main.mjs`) | ✅ 190 行 | +| ② 真实链路 e2e(本地 agent → connect → invoke → 事件 → 错误 → disconnect) | ✅ **8/8 通过** | +| ③ 单文件打包(`sidecar/build.mjs`,esbuild JS API + createRequire shim) | ✅ 227KB,**仓库外可独立运行** | +| ④ Rust 壳改造(内部换 sidecar,command 签名不变 → 前端零改动) | ⬜ 待做 | +| ⑤ `tauri.conf.json` 的 `externalBin` / resource 配置 + Node 运行时打包 | ⬜ 待做 | +| ⑥ 退役 `crates/nodeagent-client`(删依赖 + rust.yml) | ⬜ 待做 | + +### 打包说明(步骤 ⑤ 的既定方向) + +sidecar 是 `.mjs`,需要 Node 解释器 —— **不满足 Tauri `externalBin`(要求可执行文件)**。 +采用与 `pack:win` 一致的既有做法:**sidecar.mjs + Node 运行时都作为 Tauri resource**, +Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node)。 +(`externalBin` 若要走,需 Node SEA 打成真二进制,但其动态 require 限制与 `ws` 有冲突,故不采用。) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e234f15..af8cdfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: apps/console: dependencies: + '@nodeagent/client': + specifier: workspace:* + version: link:../../packages/client + '@nodeagent/protocol': + specifier: workspace:* + version: link:../../packages/protocol '@tauri-apps/api': specifier: ^2 version: 2.12.1