feat(console): 采纳 sidecar 方案(选项 D 第一步)—— Node sidecar 实现 + 8/8 真机 e2e

## 为什么
原设计让 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
This commit is contained in:
jkooe committed 2026-10-10 15:11:39 +08:00
1 parent e23788d717
commit 096193a8ec
5 files changed
+347 -2

No files matched your search

+6 -2
View File
@@ -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",
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
*
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
*
* 为什么用 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`);
+210
View File
@@ -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})`);
+73
View File
@@ -319,3 +319,76 @@ cargo run --example probe -- ws://127.0.0.1:8765 <key> /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` 有冲突,故不采用。)
+6
View File
@@ -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