dsh-lan-peers 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDOVER.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  | 位置 | 形态 | 版本 |
13
13
  |---|---|---|
14
- | npm | 公开包 `dsh-lan-peers`,`latest` | **0.4.0**(2026-10-27 发布,34 文件,shasum `204999eb…`)|
14
+ | npm | 公开包 `dsh-lan-peers`,`latest` | **0.7.0**(2026-10-27 发布;51 文件 / 231.7 kB;含群聊 Phase1+2 与 S1/S2/S3)|
15
15
  | NAS `192.168.5.51`(fn-302,Debian 12) | `link:` 安装进 `/home/ai/.dsh/profiles/web`,systemd `dsh-web` 托管 | **0.4.2**(2026-10-27 20:35 部署;含 unpair + admin 审计日志)|
16
16
  | Windows 桌面版(Electron) | `link:D:/DS-Harness/lan-peers`(见 §7)| **0.4.0**(2026-10-27 重启生效;面板与 SSE 派单已实测)|
17
17
 
@@ -352,6 +352,8 @@ node interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→旧
352
352
 
353
353
  **发布记录**
354
354
 
355
+ - **0.7.0 → npm(2026-10-27)**:`npm publish` 成功(granular token,无 OTP);51 文件 / 231.7 kB;注册表传播比上次慢(约 2 分钟后 `latest` 才变 0.7.0,期间 `npm view` 仍报 0.4.0 —— **拿不准时先查 `dist-tags` 再重试,别急着重发**)。临时 profile 真装验证:`installed 0.7.0`、`bin.dsh-lan-peers=client/cli.mjs`、`lib/group-client.mjs` 在包内、`interop/` 未打包(符合 `files` 白名单)。
356
+
355
357
  - 0.4.0 · 2026-10-27 · `npm publish` 成功(granular token,无 OTP)·34 文件 / 87.9 kB · shasum `204999ebe7fa3190fd2603c81ab4d96767e52538`;NAS 与 npm 均已 0.4.0,桌面待重启。
356
358
  - **0.4.1 → NAS(2026-10-27 20:27)**:未发 npm,只部署 NAS。备份 `/tmp/lan-peers-backup-0.4.0-20261027T122711Z.tgz`(88660B,sha256 `5185a9cb…`);包内 45 条目、双端 sha256 一致、覆盖式解包;`dsh-web` active;health = `0.4.1` + capability 含 `unpair`;真实派单走 SSE 成功(elapsed 1227ms)。
357
359
  - **0.4.2 → NAS(2026-10-27 20:35)**:只部署 NAS,未发 npm。备份 `/tmp/lan-peers-backup-0.4.1-20261027T123459Z.tgz`(116433B,49 条目);包 46 条目、双端 sha256 `675ac1d9…` 一致、覆盖解包;health = `0.4.2` + `unpair`;`[admin] pairing code issued ttl=600s` 实测出现且**日志无 6 位码**(正则命中 0);真实派单 reply「二次部署」;NAS `peers` 1→1 不变。
@@ -43,7 +43,9 @@ async function startHub(t, overrides = {}) {
43
43
  const server = createPeerServer({
44
44
  listenHost: '127.0.0.1',
45
45
  stateDir,
46
- group: { enabled: true, autoJoin: true },
46
+ // S3:majority 现在真的走 propose/commit(假成员没有投票端点会 503);
47
+ // 这个文件测的是 v0.5 的 CLI 行为,固定 quorum:none = 写本地即提交。
48
+ group: { enabled: true, autoJoin: true, quorum: 'none' },
47
49
  ...overrides,
48
50
  });
49
51
  const info = await server.listen(0, '127.0.0.1');
@@ -161,7 +163,7 @@ test('group tail 跟随事件流并逐条打印(期间别人发的消息也收
161
163
  });
162
164
 
163
165
  test('group join 在 hub 未开 autoJoin 时 exit 2 并说明原因', async (t) => {
164
- const hub = await startHub(t, { group: { enabled: true, autoJoin: false } });
166
+ const hub = await startHub(t, { group: { enabled: true, autoJoin: false, quorum: 'none' } });
165
167
  const dir = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hub.server.store.instanceId });
166
168
 
167
169
  const out = await runAsync(['group', 'join', '--peer', 'hub', '--state-dir', dir]);
package/client/cli.mjs CHANGED
@@ -698,7 +698,8 @@ async function cmdGroupClaim(dir, args) {
698
698
  }
699
699
  process.stderr.write(
700
700
  '接管未成功: ' + (reply?.reason || 'vote-refused')
701
- + ' 票数不足(quorum=' + (reply?.needed ?? '?') + ')或本机不在群内\n',
701
+ + ' 票数=' + (reply?.votes ?? '?') + '/' + (reply?.needed ?? '?')
702
+ + '(成员表 ' + (reply?.memberCount ?? '?') + ' 人;多数派分母是成员表全集,不是可达成员数)\n',
702
703
  );
703
704
  process.exitCode = 2;
704
705
  }
package/host/server.mjs CHANGED
@@ -96,7 +96,7 @@ import { parseArgs } from 'node:util';
96
96
  // ---------------------------------------------------------------------------
97
97
 
98
98
  /** 插件版本,用于 /v1/health.version */
99
- export const PLUGIN_VERSION = '0.7.0';
99
+ export const PLUGIN_VERSION = '0.7.1';
100
100
 
101
101
  /** §4.1 允许的时钟偏移 */
102
102
  export const CLOCK_SKEW_MS = 60_000;
@@ -2938,13 +2938,19 @@ export function createPeerServer(overrides = {}) {
2938
2938
  writeGroupKeyFile(config.stateDir, { ...existing, members });
2939
2939
  }
2940
2940
 
2941
- /** 对端可达地址:学到的 > hub 群状态里记的 > peers.json 配对时的地址 */
2941
+ /**
2942
+ * 对端可达地址:学到的 > 群成员表(group.json / group-key.json,读盘即最新)> peers.json。
2943
+ * 群成员表必须排在 peers.json 前面:成员节点被 bootstrap 之前,peers.json 里可能是旧地址。
2944
+ */
2942
2945
  function baseUrlOf(peerId) {
2943
2946
  const id = String(peerId ?? '');
2944
2947
  if (id === '') return '';
2945
2948
  if (id === store.instanceId) return localAdvertiseBaseUrl();
2946
2949
  const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
2947
2950
  if (learned !== '') return learned;
2951
+ const fromGroup = String(localGroupContext()?.members?.[id]?.baseUrl ?? '')
2952
+ .trim().replace(/\/+$/, '');
2953
+ if (fromGroup !== '') return fromGroup;
2948
2954
  const recorded = String(groupStore ? groupStore.state().members[id]?.baseUrl ?? '' : '')
2949
2955
  .trim().replace(/\/+$/, '');
2950
2956
  if (recorded !== '') return recorded;
@@ -2971,21 +2977,20 @@ export function createPeerServer(overrides = {}) {
2971
2977
  }
2972
2978
 
2973
2979
  /**
2974
- * 承诺集合(决定 quorum 与 propose 目标):自己 + 在网络上自报过地址的成员。
2975
- * 依据:契约 §5 的 ack 只能来自**能投递到**的成员;从未在网络上出现过的成员
2976
- * 既收不到提案也不可能 ack,把它算进多数派只会让整个群永远写不进去。
2977
- * 三成员真机群里所有成员都会自报地址(probe/pull/replicate),此时与
2978
- * 契约的「成员总数含自己」完全等价。
2980
+ * 承诺集合 = **成员表全集**(含自己,去重)。
2981
+ * 分母绝不能按「知道地址」过滤:那会让刚启动/还没学到地址的节点把分母压到 1,
2982
+ * 于是自投即当选(2026-10-27 真机事故:桌面节点在两节点群里 epoch=2 自立为 leader)。
2983
+ * 地址只决定「提案/拉票发给谁」,不决定多数派的分母(契约 §3:N = 成员总数含自己)。
2979
2984
  */
2980
2985
  function quorumMembers() {
2981
- return [store.instanceId, ...groupMemberIds().filter(
2982
- (id) => id !== store.instanceId && knownMemberAddress(id) !== '',
2983
- )];
2986
+ const ids = new Set(groupMemberIds());
2987
+ ids.add(store.instanceId);
2988
+ return [...ids];
2984
2989
  }
2985
2990
 
2986
- /** 提案目标 = 承诺集合里除自己以外的成员 */
2991
+ /** 提案目标 = 除自己以外、地址已知(含 peers.json 兜底)的成员;地址缺失只影响投递 */
2987
2992
  function proposeTargets() {
2988
- return quorumMembers().filter((id) => id !== store.instanceId);
2993
+ return peerTargets();
2989
2994
  }
2990
2995
 
2991
2996
  /** 契约 §3:majority = floor(N/2)+1(N 含自己),none = 1 */
@@ -3255,8 +3260,11 @@ export function createPeerServer(overrides = {}) {
3255
3260
  return { ok: false, reason: 'no-group-key' };
3256
3261
  }
3257
3262
  const targets = peerTargets();
3263
+ // 成员表里有、但地址未知/不可达的成员:它们不可能投票,票数自然不足
3264
+ const unreachableBase = Math.max(0, quorumMembers().length - 1 - targets.length);
3258
3265
  let votes = 1;
3259
3266
  let ahead = null;
3267
+ let unreachable = unreachableBase;
3260
3268
  const tasks = targets.map(async (peerId) => {
3261
3269
  try {
3262
3270
  const reply = await requestGroupVote({
@@ -3276,6 +3284,8 @@ export function createPeerServer(overrides = {}) {
3276
3284
  } catch (error) {
3277
3285
  const body = error?.body;
3278
3286
  if (Number(body?.epoch) > epoch) ahead = body;
3287
+ // 不可达成员只计数(lost 那行日志里有 unreachable=N),不逐条刷日志
3288
+ unreachable += 1;
3279
3289
  return false;
3280
3290
  }
3281
3291
  });
@@ -3315,8 +3325,12 @@ export function createPeerServer(overrides = {}) {
3315
3325
  // 重试窗口带抖动:两台落败者不会在同一毫秒又同时宣布候选
3316
3326
  leaseUntil: config.now() + leaseMs + Math.floor(Math.random() * leaseMs),
3317
3327
  });
3318
- if (config.logger) config.logger(`[group] election lost epoch=${epoch} votes=${votes}/${needed} reason=${reason}`);
3319
- return { ok: false, reason: 'quorum-unavailable', epoch, votes, needed };
3328
+ if (config.logger) {
3329
+ config.logger(
3330
+ `[group] election lost epoch=${epoch} votes=${votes}/${needed} unreachable=${unreachable} reason=${reason}`,
3331
+ );
3332
+ }
3333
+ return { ok: false, reason: 'quorum-unavailable', epoch, votes, needed, unreachable };
3320
3334
  } finally {
3321
3335
  electionInFlight = false;
3322
3336
  }
@@ -3420,6 +3434,9 @@ export function createPeerServer(overrides = {}) {
3420
3434
  commitSeq: st.commitSeq,
3421
3435
  pendingCount: pendingCount(),
3422
3436
  quorum: quorumMode,
3437
+ // 分母与门槛:成员表全集(含没有地址的成员),地址只影响投递
3438
+ memberCount: quorumMembers().length,
3439
+ quorumNeeded: quorumNeeded(),
3423
3440
  degraded: isDegraded(),
3424
3441
  advertiseBaseUrl: localAdvertiseBaseUrl(),
3425
3442
  },
@@ -3515,6 +3532,10 @@ export function createPeerServer(overrides = {}) {
3515
3532
  epoch: after.epoch,
3516
3533
  role: after.role,
3517
3534
  leaderPeerId: after.leaderPeerId,
3535
+ // 运维要能看出「差几票」:分母是成员表全集,不是可达成员数
3536
+ votes: out?.votes ?? null,
3537
+ needed: out?.needed ?? null,
3538
+ memberCount: quorumMembers().length,
3518
3539
  },
3519
3540
  peerId: auth.peerId,
3520
3541
  };
@@ -3727,7 +3748,17 @@ export function createPeerServer(overrides = {}) {
3727
3748
  const method = String(req.method ?? 'GET').toUpperCase();
3728
3749
  // S3:成员节点只有当上 leader 才收 post(否则这些路径仍当作不存在,v0.5.0 语义不变)
3729
3750
  const leaderPost = !groupStore && method === 'POST' && url.pathname === '/v1/group/post' && isLocalLeader();
3730
- if (!groupStore && !leaderPost) return { status: 404, body: { error: 'not-found' } };
3751
+ if (!groupStore && !leaderPost) {
3752
+ // 成员节点上「post 打到非 leader」与「群没启用」要能分辨:状态码保持 404(v0.5 语义不变),
3753
+ // 但正文与日志点明原因,免得和「leader 拿不到多数派 503」混在一起。
3754
+ if (groupRole !== 'off' && method === 'POST' && url.pathname === '/v1/group/post') {
3755
+ if (config.logger) {
3756
+ config.logger(`[group] post refused reason=not-leader epoch=${roleState()?.epoch ?? 0}`);
3757
+ }
3758
+ return { status: 404, body: { error: 'not-leader' }, peerId: null };
3759
+ }
3760
+ return { status: 404, body: { error: 'not-found' } };
3761
+ }
3731
3762
  if (!GROUP_V1_PATHS.includes(url.pathname)) return { status: 404, body: { error: 'not-found' } };
3732
3763
  if (method === 'GET' && url.pathname === '/v1/group/state') return groupState(req, rawBody);
3733
3764
  if (method === 'POST' && url.pathname === '/v1/group/join') return groupJoin(req, rawBody);
@@ -36,11 +36,13 @@ const OUTSIDER = peerFixture('peer-group-auth-2');
36
36
  async function startHub(t, overrides = {}) {
37
37
  const stateDir = overrides.stateDir ?? makeTempStateDir();
38
38
  seedPeers(stateDir, [MEMBER, OUTSIDER]);
39
+ // S3:与 group.test.mjs 同理 —— 假 peerId 成员无法 ack,固定 quorum:none。
40
+ const group = { enabled: true, name: 'default', autoJoin: false, retentionDays: 7, quorum: 'none' };
39
41
  const started = await startServer(t, {
40
42
  stateDir,
41
43
  allowRemotePrompts: true,
42
- group: { enabled: true, name: 'default', autoJoin: false, retentionDays: 7 },
43
44
  ...overrides,
45
+ group: { ...group, ...(overrides.group ?? {}) },
44
46
  });
45
47
  return started;
46
48
  }
@@ -16,8 +16,9 @@ import { createPeerServer } from '../server.mjs';
16
16
  import { writeGroupKeyFile } from '../../lib/group-client.mjs';
17
17
  import { makeTempStateDir, request, writeJson0600 } from './helpers.mjs';
18
18
 
19
- export const heartbeatMs = 60;
20
- export const commitTimeoutMs = 200;
19
+ // 全量测试是并行跑的,机器很忙:留足余量,避免「提交窗口/投票超时」偶发红
20
+ export const heartbeatMs = 120;
21
+ export const commitTimeoutMs = 600;
21
22
  export const tickMs = 25;
22
23
 
23
24
  export const secretOf = (peerId) => `sec-${peerId}`;
@@ -56,7 +57,7 @@ export function postAsEncrypted(baseUrl, fromId, body, extra = {}) {
56
57
  return postAs(baseUrl, fromId, body, { encrypt: true, ...extra });
57
58
  }
58
59
 
59
- export async function waitFor(check, { timeoutMs = 3_000, intervalMs = 20 } = {}) {
60
+ export async function waitFor(check, { timeoutMs = 6_000, intervalMs = 20 } = {}) {
60
61
  const deadline = Date.now() + timeoutMs;
61
62
  let last;
62
63
  while (Date.now() < deadline) {
@@ -133,7 +134,7 @@ export async function startCluster(t, options = {}) {
133
134
  commitTimeoutMs: commitOverride,
134
135
  tickMs: tickOverride,
135
136
  jitterMs,
136
- peerTimeoutMs: 50,
137
+ peerTimeoutMs: 250,
137
138
  },
138
139
  });
139
140
  const info = await server.listen(0, '127.0.0.1');
@@ -86,7 +86,7 @@ test('S3 ack 不足:成员全部下线 → 503、消息不可见、seq 不被
86
86
  });
87
87
 
88
88
  test('S3 提交超时:对端接受连接但不响应 → 等到 commitTimeoutMs 才 503,消息仍不可见', async (t) => {
89
- const cluster = await startCluster(t, { count: 3 });
89
+ const cluster = await startCluster(t, { count: 3, commitTimeoutMs: 200 });
90
90
  const [a, b, c] = cluster.nodes;
91
91
  // 黑洞:TCP 握手成功但永不回 HTTP 响应(模拟半死节点,和 ECONNREFUSED 区分开)
92
92
  const sockets = new Set();
@@ -77,7 +77,7 @@ test('S3 租约过期:leader 被杀的 ≤3 个租约内自动接任,epoch+1
77
77
  await a.server.close();
78
78
  const survivors = [b, c];
79
79
  const winner = await waitFor(() => survivors.find((node) => roleOf(node).role === 'leader'), {
80
- timeoutMs: 3_000,
80
+ timeoutMs: 8_000,
81
81
  intervalMs: 10,
82
82
  });
83
83
  assert.ok(winner, '杀掉 leader 后必须有一台接任');
@@ -331,6 +331,74 @@ test('S3 同 epoch 分裂投票:自投的候选让位给裁决更合法的一
331
331
  assert.equal(refused.reason, 'lower-priority');
332
332
  assert.deepEqual(readRoleFile(c).votedFor, { epoch: 2, peerId: a.peerId });
333
333
  });
334
+ // ---------------------------------------------------------------------------
335
+ // §3 quorum 分母(安全不变量:分母是成员表全集,不是「知道地址的成员」)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ test('S3 quorum 分母 = 成员表全集:没有任何地址的成员也计入,且能真的挡住写入', async (t) => {
339
+ const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
340
+ const [a, b] = cluster.nodes;
341
+ const lease = (node) => fetchGroupLease({ baseUrl: node.baseUrl, ...groupArgs(cluster, b.peerId) });
342
+
343
+ const baseline = await lease(a);
344
+ assert.equal(baseline.memberCount, 3, 'hub 成员表 = 自己 + 两台');
345
+ assert.equal(baseline.quorumNeeded, 2);
346
+
347
+ // 三个只存在于成员表、从来没有地址也从不在线的成员:分母必须跟着变大
348
+ a.server.group.addMember('peer-ghost-1', 'ghost-1');
349
+ a.server.group.addMember('peer-ghost-2', 'ghost-2');
350
+ a.server.group.addMember('peer-ghost-3', 'ghost-3');
351
+ const grown = await lease(a);
352
+ assert.equal(grown.memberCount, 6, '分母等于成员表条数(含 3 个无地址成员)');
353
+ assert.equal(grown.quorumNeeded, 4, 'floor(6/2)+1');
354
+
355
+ // 分母变大后,三台可达只凑得出 3 票 → 503(幽灵成员没有地址也必须算进多数派)
356
+ const blocked = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'ghost-blocked' });
357
+ assert.equal(blocked.status, 503, blocked.text);
358
+ assert.equal(blocked.json.error, 'quorum-unavailable');
359
+ assert.deepEqual(a.server.group.history({ sinceSeq: 0 }), []);
360
+
361
+ // 清掉幽灵成员后恢复可写(票数 3 ≥ 3)
362
+ for (const id of ['peer-ghost-1', 'peer-ghost-2', 'peer-ghost-3']) a.server.group.removeMember(id);
363
+ const ok = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'ghost-gone' });
364
+ assert.equal(ok.status, 201, ok.text);
365
+ assert.equal((await lease(a)).quorumNeeded, 2);
366
+ });
367
+
368
+ test('S3 单节点自立为 leader 必败:N=2 且另一台不可达 → quorum-unavailable', async (t) => {
369
+ // 这一条锁死真机事故:桌面节点曾经在 NAS 不可达时 epoch=2 自立为 leader
370
+ const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
371
+ const [a, b] = cluster.nodes;
372
+ await a.server.close();
373
+
374
+ const out = await b.server.group.elect({ reason: 'test-lonely' });
375
+ assert.equal(out.ok, false, JSON.stringify(out));
376
+ assert.equal(out.reason, 'quorum-unavailable');
377
+ assert.equal(out.votes, 1, '只有自己那一票');
378
+ assert.equal(out.needed, 2, 'N=2 → floor(2/2)+1 = 2');
379
+ assert.equal(roleOf(b).role, 'follower', '绝不能自立为 leader');
380
+ assert.equal(roleOf(b).leaderPeerId, '');
381
+ });
382
+
383
+ test('S3 N=3:两台可达能当选(2 ≥ 2),只剩一台不能(1 < 2)', async (t) => {
384
+ const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
385
+ const [a, b, c] = cluster.nodes;
386
+ await a.server.close();
387
+
388
+ const won = await b.server.group.elect({ reason: 'test-two-alive' });
389
+ assert.equal(won.ok, true, JSON.stringify(won));
390
+ assert.equal(won.votes, 2);
391
+ assert.equal(won.needed, 2);
392
+ assert.equal(roleOf(b).role, 'leader');
393
+
394
+ await b.server.close();
395
+ const lonely = await c.server.group.elect({ reason: 'test-one-alive' });
396
+ assert.equal(lonely.ok, false, JSON.stringify(lonely));
397
+ assert.equal(lonely.reason, 'quorum-unavailable');
398
+ assert.equal(lonely.votes, 1);
399
+ assert.equal(lonely.needed, 2);
400
+ assert.notEqual(roleOf(c).role, 'leader');
401
+ });
334
402
  // 说明:post 走 per-peer 签名,这里顺带确认 leader 真的能收(详情见 group-commit.test.mjs)
335
403
  test('S3 leader 就绪:hub leader 的 post 通道可用(202/201 由 commit 测试覆盖)', async (t) => {
336
404
  const cluster = await startCluster(t, { count: 2, quorum: 'none' });
@@ -31,11 +31,15 @@ const OUTSIDER = peerFixture('peer-group-2');
31
31
  async function startHub(t, overrides = {}) {
32
32
  const stateDir = overrides.stateDir ?? makeTempStateDir();
33
33
  seedPeers(stateDir, [MEMBER, OUTSIDER]);
34
+ // S3:majority 下 post 走 propose→commit,假 peerId 成员没有投票端点必然 503;
35
+ // 这个文件测的是 v0.5 端点语义,固定 quorum:none(写本地即提交)。
36
+ const group = { enabled: true, name: 'default', autoJoin: false, retentionDays: 7, quorum: 'none' };
34
37
  const started = await startServer(t, {
35
38
  stateDir,
36
39
  allowRemotePrompts: true,
37
- group: { enabled: true, name: 'default', autoJoin: false, retentionDays: 7 },
38
40
  ...overrides,
41
+ // overrides.group 只覆盖给出的字段,别把 quorum:none 顶掉
42
+ group: { ...group, ...(overrides.group ?? {}) },
39
43
  });
40
44
  return started;
41
45
  }
@@ -373,7 +377,8 @@ test('同名成员在 state / history / admin 门面里都能区分(渲染层
373
377
  const { baseUrl, server } = await startServer(t, {
374
378
  stateDir,
375
379
  allowRemotePrompts: true,
376
- group: { enabled: true, autoJoin: true },
380
+ // S3:假 peerId 成员没有投票端点 → 固定 quorum:none(与 startHub 同一口径)
381
+ group: { enabled: true, autoJoin: true, quorum: 'none' },
377
382
  });
378
383
 
379
384
  const join = (peer) => request(baseUrl, {
@@ -661,7 +666,8 @@ test('§6 进度镜像:start/finish 两条落进群,且不含 prompt 原文
661
666
  const { baseUrl: hubUrl, server: hub } = await startServer(t, {
662
667
  stateDir: hubState,
663
668
  allowRemotePrompts: true,
664
- group: { enabled: true, autoJoin: true },
669
+ // S3:镜像用例同样固定 quorum:none(写本地即提交,与 v0.5 语义一致)
670
+ group: { enabled: true, autoJoin: true, quorum: 'none' },
665
671
  });
666
672
  hub.group.addMember(MEMBER.peerId, MEMBER.name);
667
673
 
@@ -417,10 +417,13 @@ test('hub 侧:未启用群的 host 一律回 enabled:false(契约 §7.3)',
417
417
  test('成员侧:GET/post 由插件进程代理到 hub(含 groupKey 剥离),浏览器不直连对端', async (t) => {
418
418
  const hubDir = makeTempStateDir();
419
419
  seedPeers(hubDir, [{ peerId: 'member-inst', name: 'me', secret: 'H2M', inboundSecret: 'M2H', baseUrl: 'http://127.0.0.1:1' }]);
420
+ // S3 起 majority 两阶段提交生效:本夹具里成员没有起真实服务器(baseUrl 打不通),
421
+ // 拿不到 ack → hub 按契约 §5/§9.3 回 503 是正确行为,而这条测试考的是「管理接口
422
+ // 代理 + groupKey 剥离」,所以固定 quorum:none(可写优先)以隔离该路径。
420
423
  const hub = await startServer(t, {
421
424
  stateDir: hubDir,
422
425
  name: 'hub-box',
423
- group: { enabled: true, hub: '', name: '家里', autoJoin: true },
426
+ group: { enabled: true, hub: '', name: '家里', autoJoin: true, quorum: 'none' },
424
427
  });
425
428
  hub.server.group.addMember('member-inst', 'me');
426
429
 
@@ -750,8 +753,8 @@ test('S3 · GET 追加 role/leaseUntil/quorum/commitSeq/pendingCount/degraded:
750
753
  assert.equal(out.json.pendingCount, 2, '未提交条数来自门面');
751
754
  assert.equal(out.json.degraded, false, '有 leader 且租约未过期');
752
755
 
753
- // 旧字段一个不动
754
- assert.deepEqual(out.json.leader, { peerId: 'peer-hub', name: 'hub-box', epoch: 4 });
756
+ // 旧字段的取法不变,但 epoch 改为以角色门面为准(这里是 9,群元数据是 4)
757
+ assert.deepEqual(out.json.leader, { peerId: 'peer-hub', name: 'hub-box', epoch: 9 });
755
758
  assert.equal(out.json.selfRole, 'member');
756
759
  assert.equal(out.json.lastSeq, 8);
757
760
  assert.deepEqual(out.json.messages, MESSAGES);
@@ -823,6 +826,43 @@ test('S3 · degraded:租约过期 / 门面明确没有 leader 才报警,缺
823
826
  assert.equal(none.json.quorum, 'none', 'group.quorum=none 如实透给面板');
824
827
  });
825
828
 
829
+ test('S3 · leader.epoch 以角色门面为准:角色 3 / 群元数据 1 → 3;门面缺席回退旧值', async () => {
830
+ // 真机(NAS 0.7.0)现象:group.json 的 keyVersion 世代是 1,角色文件已经是 3。
831
+ const metadataEpochOne = () => ({
832
+ groupId: 'g-1', name: 'default', epoch: 1, members: MEMBERS, messages: MESSAGES, lastSeq: 8,
833
+ });
834
+ const roleEpochThree = () => ({
835
+ role: 'leader', epoch: 3, leaderPeerId: 'inst-self', leaseUntil: Date.now() + 9000,
836
+ commitSeq: 8, pendingCount: 0,
837
+ });
838
+
839
+ const hubSide = await hit(stubAdmin({
840
+ group: { enabled: true, hub: '' },
841
+ api: { ...makeApi(), isHub: true, state: metadataEpochOne },
842
+ facade: { enabled: true, isHub: true, role: roleEpochThree },
843
+ }).admin, 'GET', '/api/lan-peers/group');
844
+ assert.equal(hubSide.json.leader.epoch, 3, 'leader.epoch 必须是选举世代,不是 group.json 的 keyVersion');
845
+ assert.equal(hubSide.json.leader.peerId, 'inst-self', 'peerId 取法不变');
846
+ assert.equal(hubSide.json.leader.name, 'self', 'name 取法不变');
847
+ assert.equal(hubSide.json.role, 'leader');
848
+
849
+ const memberSide = await hit(stubAdmin({
850
+ group: { enabled: true, hub: 'hub-box' },
851
+ api: { ...makeApi(), isHub: false, state: metadataEpochOne },
852
+ peers: HUB_PEERS,
853
+ facade: { enabled: false, isHub: false, role: () => ({ role: 'follower', epoch: 3, leaderPeerId: 'peer-hub' }) },
854
+ }).admin, 'GET', '/api/lan-peers/group');
855
+ assert.equal(memberSide.json.leader.epoch, 3, '成员侧同样以角色世代为准');
856
+ assert.equal(memberSide.json.leader.peerId, 'peer-hub', '成员侧 peerId 仍取 group.hub 指向的 peer');
857
+
858
+ // 角色门面缺席(老 host / M 未接线):回到原来那个值,旧行为不破
859
+ const fallback = await hit(stubAdmin({
860
+ group: { enabled: true, hub: '' },
861
+ api: { ...makeApi(), isHub: true, state: metadataEpochOne },
862
+ }).admin, 'GET', '/api/lan-peers/group');
863
+ assert.equal(fallback.json.leader.epoch, 1, '没有角色门面时回退到群元数据的 epoch');
864
+ });
865
+
826
866
  test('S3 · 群状态快照里的角色字段是第二来源(门面缺席时也能显示)', async () => {
827
867
  const leaseUntil = Date.now() + 9000;
828
868
  const api = apiWith({ isHub: true, lastSeq: 8 });
package/lib/admin.mjs CHANGED
@@ -339,9 +339,15 @@ export function createAdmin({
339
339
  return typeof fromConfig === 'string' ? fromConfig : '';
340
340
  });
341
341
 
342
- /** S1:leader —— hub 侧就是本机,成员侧就是 group.hub 指向的那个 peer。 */
343
- const leaderOf = (snapshot, resolved) => {
344
- const epoch = asSeqOrNull(snapshot?.epoch);
342
+ /**
343
+ * S1:leader —— hub 侧就是本机,成员侧就是 group.hub 指向的那个 peer。
344
+ *
345
+ * S3:epoch 优先取角色门面的 `epoch`(选举世代),只有角色门面缺席时才回退到
346
+ * 群元数据的 epoch。两者含义不同(群元数据那份是 group.json 的 keyVersion 世代),
347
+ * 真机上出现过「leader.epoch=1 而状态行 epoch=3」的显示打架,这里统一到选举世代。
348
+ */
349
+ const leaderOf = (snapshot, resolved, face = null) => {
350
+ const epoch = asSeqOrNull(face?.epoch) ?? asSeqOrNull(snapshot?.epoch);
345
351
  if (resolved.isHub === true) {
346
352
  return {
347
353
  peerId: String(store?.instanceId ?? ''),
@@ -395,8 +401,8 @@ export function createAdmin({
395
401
  * 来源优先级:host 门面 > 群状态快照 > 本文件兜底(hub=leader,成员=follower)。
396
402
  * 缺字段时给保守值而不是编造报警:只有「明确没有 leader」或「租约已过期」才算 degraded。
397
403
  */
398
- const roleView = ({ isHub, settings, snapshot = null, leader = null, lastSeq = null }) => {
399
- const face = roleFace() ?? {};
404
+ const roleView = ({ isHub, settings, snapshot = null, leader = null, lastSeq = null, state = null }) => {
405
+ const face = state ?? roleFace() ?? {};
400
406
  const snap = snapshot && typeof snapshot === 'object' ? snapshot : {};
401
407
  const known = [face.role, snap.role].find((value) => GROUP_ROLES.includes(String(value ?? '')));
402
408
  const leaseUntil = asSeqOrNull(face.leaseUntil) ?? asSeqOrNull(snap.leaseUntil);
@@ -709,6 +715,9 @@ export function createAdmin({
709
715
  // v0.6.0 S1/S2 的可见性字段:只加不改,未启用分支保持旧形状。
710
716
  const selfRole = isHub ? 'hub' : 'member';
711
717
  const advertise = advertiseAddress();
718
+ // S3:一次请求只读一次角色门面 —— leader.epoch 与状态行的 epoch 必须同源,
719
+ // 否则两次读取之间换 leader 就会在同一条响应里自相矛盾。
720
+ const roleState = roleFace();
712
721
  // v0.7.0 S3(契约 §7):角色/租约/quorum/提交进度只加不改;失败分支也带上,
713
722
  // 面板据此显示 degraded,而不是留一片空白。
714
723
  const extras = (leader, replica, selfBaseUrl, snapshot = null, lastSeq = null) => ({
@@ -723,6 +732,7 @@ export function createAdmin({
723
732
  snapshot,
724
733
  leader,
725
734
  lastSeq,
735
+ state: roleState,
726
736
  settings: resolved.settings ?? groupSettings(),
727
737
  }),
728
738
  });
@@ -750,7 +760,7 @@ export function createAdmin({
750
760
  const view = memberView(member);
751
761
  return { ...view, lag: view.lastSeq == null ? null : Math.max(0, lastSeq - view.lastSeq) };
752
762
  });
753
- const leader = leaderOf(snapshot, resolved);
763
+ const leader = leaderOf(snapshot, resolved, roleState);
754
764
  const replica = isHub
755
765
  ? { lastSeq, leaderLastSeq: lastSeq, lag: 0 }
756
766
  : replicaView(coreReplica() ?? readReplica(), lastSeq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lan-peers",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "局域网 DSH 协同:一次性配对互认、HMAC 签名 + AES-256-GCM 加密、agent 互派任务",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",