Files
jkooe 66c44f6822 feat(E3): Hub 并发多控制端 + 设备分组(阶段 E 全部完成)
## E3a 并发多控制端(Hub 槽位池)
过去:一个被控端只有一条 Hub 连接 -> 同时只能一个控制端(否则 E_NODE_BUSY)。
现在:
- Hub 侧 agents 表改为「node_id -> 槽位池」,每个槽位服务一个控制端:
  有空闲槽位直接配对;不足且未达上限则向被控端请求扩容(need_slot,3s 超时);
  仍不满足才返回 E_NODE_BUSY。新增配置 max_slots_per_node(默认 3,设 1 即退回独占语义)
- 被控端侧 hub-mode 重写为槽位池:常备 2 条连接(warm_slots 可调),
  并在某槽位被占用(收到 hello)时预热下一条直到 max_slots
  —— 复用既有协议信号,不新增控制消息;每条槽位独立重连与退避
- Hub 列表视图聚合为「一节点一行」并带 slots / paired_slots
- e2e 新增并发用例:两个控制端同时握手 + 交替并发调用,均成功(旧版会失败)

## E3b 设备分组
- 客户端配置新增 groups(组名 -> 设备名数组;成员可写 @其它组名,最多展开 3 层防环)
- CLI:nodeagent group add|remove|list;fanout --nodes @组名 按组并发下发
- 真机验证:group add 本机 win -> fanout system.info --nodes @本机 正常

## E3c 权限隔离(部分完成)
- 能力级隔离沿用被控端 ACL(按 client_id,已含 allow/deny/glob/生效时段/IP 白黑名单/按能力限速)
- Hub 侧「按设备/按控制端授权」尚未实现(当前 Hub 视角为单一令牌),已记入路线图待办

## 测试
- 单测 73 -> 79(新增设备分组 6 例:展开 / 嵌套 / 去重 / 未知 / 兼容旧配置 / 环引用收敛)
- e2e 21/21(含扩展后的 Hub 并发双控制端用例)
2026-09-29 14:33:01 +08:00

60 lines
2.0 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveNodeSelector } from '../../packages/client/dist/index.js';
const cfg = {
client_id: 'mac_01',
current: 'win_a',
nodes: {
win_a: { host: '10.0.0.1', port: 8765 },
win_b: { host: '10.0.0.2', port: 8765 },
mac_b: { host: '10.0.0.3', port: 8765 },
},
groups: {
办公: ['win_a', 'win_b'],
全部: ['@办公', 'mac_b'],
},
};
test('分组:@组名 展开为设备列表', () => {
const r = resolveNodeSelector(cfg, ['@办公']);
assert.deepEqual(r.nodes, ['win_a', 'win_b']);
assert.deepEqual(r.resolvedGroups['办公'], ['win_a', 'win_b']);
});
test('分组:支持嵌套引用(@全部 含 @办公)', () => {
const r = resolveNodeSelector(cfg, ['@全部']);
assert.deepEqual(r.nodes, ['win_a', 'win_b', 'mac_b']);
});
test('分组:与设备名混用且去重', () => {
const r = resolveNodeSelector(cfg, ['win_b', '@办公']);
assert.deepEqual(r.nodes, ['win_b', 'win_a'], '先出现的先保留,重复项剔除');
});
test('分组:未知组展开为空(由调用方报错),未知设备名原样保留', () => {
const r = resolveNodeSelector(cfg, ['@不存在']);
assert.deepEqual(r.nodes, []);
assert.deepEqual(r.resolvedGroups['不存在'], []);
const r2 = resolveNodeSelector(cfg, ['ghost']);
assert.deepEqual(r2.nodes, ['ghost']);
});
test('分组:无 groups 字段时退化为原样返回(向后兼容旧配置)', () => {
const legacy = { client_id: 'c', current: 'win_a', nodes: { win_a: { host: 'h', port: 1 } } };
const r = resolveNodeSelector(legacy, ['win_a']);
assert.deepEqual(r.nodes, ['win_a']);
});
test('分组:环形引用不无限递归(深度上限 3)', () => {
const cyc = {
client_id: 'c',
current: 'a',
nodes: { a: { host: 'h', port: 1 } },
groups: { X: ['@Y'], Y: ['@X', 'a'] },
};
const r = resolveNodeSelector(cyc, ['@X']);
assert.ok(r.nodes.includes('a'), '应能收敛到真实设备');
assert.ok(r.nodes.length < 50, '不应因环引用爆炸');
});