dsh-lan-peers 0.10.2 → 0.10.3

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/README.md CHANGED
@@ -122,6 +122,27 @@ npx dsh-lan-peers ask --peer my-nas "在 /data/project 执行 pwd" --timeout 120
122
122
 
123
123
  对端会在自己的 `workspace` 里建真实会话、由对端模型执行,然后把最终回复加密回传。
124
124
 
125
+ ## 版本互通
126
+
127
+ 同一个群里允许混版本,但 **0.10 与 0.9.x / 0.8.4 之间有两条硬断裂**。下表是「两端各自正常工作时,用户实际看到的结果」。
128
+
129
+ | 组合 | 私聊 ask | 群聊 chat | 群内派单 | 群纪要 | 群表决 | 引用行 |
130
+ |---|---|---|---|---|---|---|
131
+ | 0.10.x ↔ 0.9.x | WORKING | WORKING | BROKEN:老节点发的 `progress`/`result` 被 0.10 hub 判 `400 {"error":"bad-request"}`,群里没有「开始/结果」回执 | DEGRADED:老 hub 的 pull 不带 `notes`,成员面板一直空白、不报错 | DEGRADED:同理;成员提提案/投票打到老 hub 得 `404 {"error":"not-found"}` | DEGRADED:老 hub 的 meta 白名单没有 `quote`,静默丢弃,只少「引用 …」那一行 |
132
+ | 0.10.x ↔ 0.8.4 | WORKING | WORKING | BROKEN:同上(0.8.4 的回执同样是 `progress`/`result`) | DEGRADED:同上 | DEGRADED:同上 | DEGRADED:同上 |
133
+
134
+ **变在哪、为什么断**
135
+
136
+ - **两条硬断裂**:① 群内派单整条线在 0.10.0 删除(`lan_group_ask`、管理面 `group/dispatch-options` / `group/accept`、执行器、回执),`GROUP_KINDS` 只剩 `['chat','system']`;② 于是老对端继续投 `progress`/`result` 回执一律 `400 {"error":"bad-request"}` —— 老节点那边只留一行 `[group] dispatch receipt failed …` 日志,群里从此看不到派单进度。**历史不会丢**:已有聊天记录里的 `progress`/`result` 仍照常显示成一行系统行。
137
+ - **一条软断裂**:`meta.quote` 是 0.10 新增字段,老 hub 的 meta 白名单没有它,**静默丢弃、不报错**(HTTP 仍是 200)。代价是引用行消失,以及「首答胜」的去重判据在跨版本时失效。
138
+ - **身份面不是断裂**:0.8.4 只有 v1 群级鉴权(`X-Peer-Auth: group` + groupKey HMAC),0.9 起有 v2(证书 + ed25519),0.10 默认两种都收(`allowLegacyGroupAuth` 默认 true)。
139
+ - **私聊 ask 形状没变**:`/v1/answer` 三代一直是 `{status, reply|error, elapsedMs}`;0.10 只多一个错误码 `empty-reply`(对端跑完却没给正文),老调用方按 `peer error: …` 呈报即可。
140
+ - `acceptDispatch` 双向安全:0.10 出站不再发 `X-Peer-Accept`,入站遇到老字段直接丢弃;老 hub 缺这个字段按「接单」处理。
141
+
142
+ **升级口径**:hub 与所有成员**在同一个窗口内一起升到 0.10** —— 跨 0.10 线的群内派单双向都不工作,纪要与表决也要 hub 先到 0.10 才有数据。
143
+
144
+ > 证据:每格都能追到代码或用例(`host/server.mjs`、`lib/group-*.mjs`、`host/test/group.test.mjs`、`lib/panel-group.test.mjs`)。`interop/run-interop.mjs` 的六行 A–F 目前只覆盖 **0.3.1 基线 ↔ 当前版本**,**不含**上面两组组合 —— 所以本表是代码路径结论,不是互通实跑结论。
145
+
125
146
  ## 安全模型
126
147
 
127
148
  - 配对码 6 位、10 分钟、一次性;每个伙伴两条独立密钥(我签给你的 / 你签给我的)。
@@ -77,8 +77,8 @@ test('t:按语言取文案,params 做 {name} 插值', () => {
77
77
  assert.match(t('en', 'cli.code.ok', { code: '123456' }), /123456/);
78
78
  // 没给的占位符原样保留:宁可露出一处 {peerId},也不抛错、不拼出半句话
79
79
  assert.equal(t('zh', 'cli.pair.ok', { label: 'x' }), '已配对: x ({peerId}) · {transport}');
80
- // 没有 params 时不做任何替换
81
- assert.equal(t('zh', 'cli.group.acceptOn'), '接单中');
80
+ // 没有 params 时不做任何替换:占位符原样留着,{label} 不会被换成字面量
81
+ assert.equal(t('zh', 'cli.pair.ok'), '已配对: {label} ({peerId}) · {transport}');
82
82
  });
83
83
 
84
84
  test('t:缺 key 回落英文,两格都缺则原样返回 key 且不抛错', () => {
package/host/server.mjs CHANGED
@@ -209,7 +209,7 @@ import { parseArgs } from 'node:util';
209
209
  // ---------------------------------------------------------------------------
210
210
 
211
211
  /** 插件版本,用于 /v1/health.version */
212
- export const PLUGIN_VERSION = '0.10.2';
212
+ export const PLUGIN_VERSION = '0.10.3';
213
213
 
214
214
  /** §4.1 允许的时钟偏移 */
215
215
  export const CLOCK_SKEW_MS = 60_000;
@@ -487,6 +487,18 @@ export const GROUP_EVENTS_PING_INTERVAL_MS = 10_000;
487
487
  /** 成员 online 的判定窗口:peers.json 的 lastSeen 在窗口内算在线 */
488
488
  export const GROUP_ONLINE_WINDOW_MS = 90_000;
489
489
 
490
+ /**
491
+ * 0.10.0-α slice 4b-3:**shelved 提案的重提醒间隔** = 6 小时。
492
+ *
493
+ * 为什么是 6 小时,不是分钟级也不是每天一次:
494
+ * · 这条提醒落在**全体成员**的群历史里 —— 一分钟一条就是刷屏,而人裁决本来不是分钟级的事;
495
+ * · 6 小时 = 一条搁置提案一天至多 4 条:不沉默,也不吵;
496
+ * · 重启**不会**立刻补发(resumeVoteTicks 重新挂的是**一整个**间隔),
497
+ * 所以「反复重启」这种最常见的抖动不会变成刷屏。
498
+ * 只提醒,绝不替人做决定:本文件里没有任何 TTL / 默认裁决 —— 交给人,不是交给时钟。
499
+ */
500
+ export const SHELVED_REMINDER_MS = 6 * 60 * 60 * 1000;
501
+
490
502
  /**
491
503
  * S1 v0.6.0:群级鉴权白名单(与 lib/group-client.mjs 同一份常量)。
492
504
  * 只有这四条路径认 X-Peer-Auth: group;其它路径带该头一律 401 group-auth-scope。
@@ -3106,6 +3118,135 @@ export function createPeerServer(overrides = {}) {
3106
3118
  voteTimers.set(proposalId, timer);
3107
3119
  }
3108
3120
 
3121
+ // -------------------------------------------------------------------------
3122
+ // 0.10.0-α slice 4b-3 · shelved 的**提醒**(只提醒,不裁决)
3123
+ //
3124
+ // 症状:一条提案第 3 轮还没过就 shelved,而它唯一的出口是面板的群页签 ——
3125
+ // 从不打开那个页签的运维者就永远不知道有决定在等他。这里补一条**声音**,
3126
+ // 一个字都不改状态机:没有 TTL、没有默认裁决、没有「到点自动生效」。
3127
+ // -------------------------------------------------------------------------
3128
+
3129
+ /**
3130
+ * shelved 提案的**重提醒**定时器:proposalId → Timeout(纪律逐条照抄 voteTimers)。
3131
+ *
3132
+ * 为什么不并进 voteTimers:那张表的语义是「**开着的轮**」—— 面板/测试都按这个读
3133
+ * (`group-replica` 断言 shelved 之后它必须是 0),而提醒的到点是「上一次提醒 + 一个间隔」,
3134
+ * 与 closesAt 不同源。混一张表会让两边的清理互相看不见(关轮清掉提醒、或提醒清掉轮),
3135
+ * 所以另起一张表,纪律一样:
3136
+ * 1. 起之前先清同一个 id 的旧定时器 —— 换新,不叠加(一条提案至多一个待触发提醒);
3137
+ * 2. 人裁决 / close() 都清;
3138
+ * 3. 一律 unref():漏清一处也不吊住进程(双保险,不是替代品)。
3139
+ */
3140
+ const shelvedReminders = new Map();
3141
+
3142
+ /** 上一次提醒的 config.now():同一窗口内的第二次触发是空操作(定时器重放不刷屏) */
3143
+ const shelvedReminderAt = new Map();
3144
+
3145
+ /** 提醒间隔:生产 6 小时;测试用 overrides.groupVote.reminderMs 缩短(与 roundMs 同一口子) */
3146
+ const shelvedReminderMs =
3147
+ Number.isFinite(Number(overrides.groupVote?.reminderMs)) && Number(overrides.groupVote.reminderMs) > 0
3148
+ ? Math.floor(Number(overrides.groupVote.reminderMs))
3149
+ : SHELVED_REMINDER_MS;
3150
+
3151
+ /**
3152
+ * 这条提案**此刻**还在等 hub 运维者裁决吗?
3153
+ * 返回 null = 不必提醒(没有 shelved 记录 / 已经裁决 / 表决记录已不是 shelved)。
3154
+ * 这就是「绝不为已采纳/已否决的提案发提醒」的唯一判据 —— 提醒前现读平面,不缓存。
3155
+ */
3156
+ function shelvedPending(proposalId) {
3157
+ if (!votesStore) return null;
3158
+ const id = String(proposalId ?? '');
3159
+ if (id === '') return null;
3160
+ const plane = votePlaneView();
3161
+ const entry = (plane.shelved ?? []).find((item) => item.proposalId === id);
3162
+ if (!entry) return null;
3163
+ if (plane.verdicts && Object.prototype.hasOwnProperty.call(plane.verdicts, id)) return null;
3164
+ const vote = plane.votes[id];
3165
+ if (vote && vote.status !== 'shelved') return null;
3166
+ return { entry, vote };
3167
+ }
3168
+
3169
+ /**
3170
+ * 提醒正文:**只有**「搁置 / 种类 / 轮数 / 只有 hub 运维者能裁决」。
3171
+ * 提案正文与备注一个字都不进去 —— 这条线落在全体成员的群历史里;id 只留 ≤8 字符。
3172
+ */
3173
+ function shelvedReminderText({ id = '', kind = '', rounds = 0 } = {}) {
3174
+ return '[group] vote shelved: proposal ' + shortVoteId(id)
3175
+ + ' kind=' + String(kind).slice(0, 16)
3176
+ + ' rounds=' + Math.max(0, Math.floor(Number(rounds) || 0))
3177
+ + ' — only the hub operator can decide; open the panel group tab to adjudicate';
3178
+ }
3179
+
3180
+ /** 清掉某条提案的重提醒(裁决/关停/换间隔都调它;重复调用安全) */
3181
+ function clearShelvedReminder(proposalId) {
3182
+ const key = String(proposalId ?? '');
3183
+ const timer = shelvedReminders.get(key);
3184
+ if (!timer) return;
3185
+ clearTimeout(timer);
3186
+ shelvedReminders.delete(key);
3187
+ }
3188
+
3189
+ /** 清掉全部重提醒定时器(close() 的唯一入口) */
3190
+ function clearShelvedReminders() {
3191
+ for (const timer of shelvedReminders.values()) clearTimeout(timer);
3192
+ shelvedReminders.clear();
3193
+ shelvedReminderAt.clear();
3194
+ }
3195
+
3196
+ /**
3197
+ * 发**一次**提醒(定时器、resume、搁置当场三处共用这一个落点)。
3198
+ *
3199
+ * 幂等两档,都在这里,不在调用方:
3200
+ * 1. 已经不算「待裁决」(裁决过 / 状态变了)→ 一个字都不发,顺手把定时器清掉;
3201
+ * 2. 同一个窗口内第二次触发 → 不发(定时器重放/重复 dispose 都撞这道闸)。
3202
+ * 写路径**不新开**:走 hub 本地门面 groupPostFacade —— 与面板发言、/task 回执同一条
3203
+ * commitLocalMessage(日志 + commitSeq + 复制队列),绝不另起一个写者。
3204
+ */
3205
+ function postShelvedReminder(proposalId) {
3206
+ const id = String(proposalId ?? '');
3207
+ const pending = shelvedPending(id);
3208
+ if (!pending) {
3209
+ clearShelvedReminder(id);
3210
+ return { emitted: false, reason: 'not-pending' };
3211
+ }
3212
+ const last = Number(shelvedReminderAt.get(id) ?? 0);
3213
+ const nowMs = config.now();
3214
+ if (last > 0 && nowMs - last < shelvedReminderMs) return { emitted: false, reason: 'same-window' };
3215
+ const proposal = votesStore.proposal(id) ?? {};
3216
+ const rounds = Number(pending.entry.rounds ?? pending.vote?.round ?? MAX_ROUNDS);
3217
+ const text = shelvedReminderText({ id, kind: proposal.kind, rounds });
3218
+ groupPostFacade({ peerId: store.instanceId, name: config.name, kind: 'system', text, meta: {} });
3219
+ shelvedReminderAt.set(id, nowMs);
3220
+ if (config.logger) {
3221
+ // 一条提醒一行日志;id ≤8、轮数、没有正文(与群消息同一口径)
3222
+ config.logger('[group] vote shelved reminder id=' + shortVoteId(id) + ' round=' + Math.max(0, Math.floor(rounds)));
3223
+ }
3224
+ return { emitted: true, reason: '' };
3225
+ }
3226
+
3227
+ /**
3228
+ * 给一条 shelved 提案挂**一个**重提醒:到点发一次,还等着就再挂一个间隔。
3229
+ * 关停闸(closing)与 voteTimers 同一条命门:close() 之后绝不再点亮定时器。
3230
+ */
3231
+ function scheduleShelvedReminder(proposalId) {
3232
+ const id = String(proposalId ?? '');
3233
+ if (id === '' || closing || !votesStore) return;
3234
+ if (!shelvedPending(id)) {
3235
+ clearShelvedReminder(id);
3236
+ return;
3237
+ }
3238
+ clearShelvedReminder(id);
3239
+ const timer = setTimeout(() => {
3240
+ shelvedReminders.delete(id);
3241
+ const out = postShelvedReminder(id);
3242
+ // 还等裁决 → 排下一间隔(一次只排一个;裁决/关停会把它清掉)
3243
+ if (out.emitted) scheduleShelvedReminder(id);
3244
+ }, shelvedReminderMs);
3245
+ timer.unref?.();
3246
+ shelvedReminders.set(id, timer);
3247
+ }
3248
+
3249
+
3109
3250
  /**
3110
3251
  * 结算一轮并推进状态机。**幂等**:只有「open → 结算」的那一次会 advance ——
3111
3252
  * 定时器重复触发、网络重放、hub 重启补结算都不会把同一个信号推进两轮(V6 的 hub 一半)。
@@ -3149,8 +3290,13 @@ export function createPeerServer(overrides = {}) {
3149
3290
  } else if (next.outcome === 'shelved') {
3150
3291
  // 第 MAX_ROUNDS 轮还没过:摘出 open,记进 shelved 集交人裁决(4b-2 的面板负责端给用户)
3151
3292
  votesStore.putVote(next.vote);
3152
- votesStore.markShelved({ proposalId: id, rounds: next.vote.round });
3293
+ const marked = votesStore.markShelved({ proposalId: id, rounds: next.vote.round });
3153
3294
  if (config.logger) config.logger(`[group] vote shelved id=${shortVoteId(id)} rounds=${next.vote.round}`);
3295
+ // 4b-3:搁置**当场**就在群里说一声 —— 没人会碰巧打开面板的群页签。
3296
+ // duplicate(同一条被重复登记)不再发第二条:一条提案的「搁置公告」只发一次。
3297
+ if (!marked.duplicate) postShelvedReminder(id);
3298
+ // 然后挂上重提醒:进程活得比这次搁置久,人可能几天后才回来。
3299
+ scheduleShelvedReminder(id);
3154
3300
  }
3155
3301
  return { ok: true, outcome: settled.outcome, closed: true, vote: settled.vote };
3156
3302
  }
@@ -3213,6 +3359,10 @@ export function createPeerServer(overrides = {}) {
3213
3359
  }
3214
3360
  return out;
3215
3361
  }
3362
+ // 4b-3:裁决落地 = 这条提案不再等任何人 —— 重提醒立刻撤掉(连「上次提醒时间」一起清),
3363
+ // 否则它会对着一条已决定的提案继续喊。
3364
+ clearShelvedReminder(proposalId);
3365
+ shelvedReminderAt.delete(String(proposalId ?? ''));
3216
3366
  if (config.logger) {
3217
3367
  // 只记短 id / 裁决 / rev:理由正文绝不上日志
3218
3368
  config.logger(`[group] adjudicate id=${shortVoteId(proposalId)} decision=${String(decision ?? '').slice(0, 8)} rev=${out.rev}`);
@@ -3252,10 +3402,15 @@ export function createPeerServer(overrides = {}) {
3252
3402
  */
3253
3403
  function resumeVoteTicks() {
3254
3404
  if (!votesStore || closing) return;
3255
- for (const vote of Object.values(votesStore.plane().votes)) {
3405
+ const plane = votesStore.plane();
3406
+ for (const vote of Object.values(plane.votes)) {
3256
3407
  if (vote.status !== 'open') continue;
3257
3408
  scheduleVoteTick(vote);
3258
3409
  }
3410
+ // 4b-3:shelved 的**不许**因为重启而沉默 —— 重新挂上一整个间隔(不是立刻补发:
3411
+ // 搁置当场那一条已经发过了,重启就补发会让「反复重启」变成刷屏)。
3412
+ // 已经裁决过的由 shelvedPending 挡掉,这里不为它起定时器。
3413
+ for (const entry of plane.shelved ?? []) scheduleShelvedReminder(entry.proposalId);
3259
3414
  }
3260
3415
 
3261
3416
  /** 真实监听端口(listen 时才知道);S1 的地址探测要用 */
@@ -9956,6 +10111,12 @@ export function createPeerServer(overrides = {}) {
9956
10111
  ),
9957
10112
  // 测试/排障可见:还挂着几个表决定时器(关轮与 close() 之后必须是 0)
9958
10113
  voteTimers: () => voteTimers.size,
10114
+ // 4b-3:还挂着几个 shelved 重提醒(裁决、close() 之后必须是 0)
10115
+ shelvedReminders: () => shelvedReminders.size,
10116
+ // 测试/排障用:立刻走一次提醒的落点(同一窗口内的第二次是空操作,不刷屏)
10117
+ remindShelved: (input) => postShelvedReminder(
10118
+ input && typeof input === 'object' ? input.proposalId : input,
10119
+ ),
9959
10120
  // 与 /v1/group/history 同一个渲染口径:同名成员在这里就已经带上短 id 后缀
9960
10121
  history: (options = {}) => decoratedMessages(options),
9961
10122
  // §18:归档索引 / 人工删除 / 游标落在归档区间的判定(管理接口与面板走这里)
@@ -10013,6 +10174,9 @@ export function createPeerServer(overrides = {}) {
10013
10174
  adjudicate: () => ({ ok: false, error: 'votes-hub-only' }),
10014
10175
  closeVote: () => ({ ok: false, error: 'votes-hub-only' }),
10015
10176
  voteTimers: () => 0,
10177
+ // 成员侧没有提醒器:提醒是 hub 的事(只读副本不挂任何定时器)
10178
+ shelvedReminders: () => 0,
10179
+ remindShelved: () => ({ emitted: false, reason: 'votes-hub-only' }),
10016
10180
  // 契约 §4 的门面在成员节点也要在:不带新字段时走既有回执通道(hub / 群级签名)
10017
10181
  post: (input) => groupPostFacade(input),
10018
10182
  advertiseBaseUrl: () => localAdvertiseBaseUrl(),
@@ -10105,6 +10269,9 @@ export function createPeerServer(overrides = {}) {
10105
10269
  }
10106
10270
  // 0.10.0-α slice 4b-1:表决定时器整表清掉(关停后 scheduleVoteTick 的 closing 闸也不再起新的)
10107
10271
  clearVoteTicks();
10272
+ // 0.10.0-α slice 4b-3:shelved 重提醒同时清 —— 两种定时器两条命门,漏一条就是
10273
+ // 「断言全过、进程不退出」的那个形状
10274
+ clearShelvedReminders();
10108
10275
  if (!httpServer.listening) return;
10109
10276
  const closed = new Promise((resolve) => httpServer.close(resolve));
10110
10277
  httpServer.closeAllConnections?.();
@@ -1310,6 +1310,99 @@ test('4b-1 V7:成员可提交提案/投票,开轮与推进一律 403 votes-h
1310
1310
  );
1311
1311
  });
1312
1312
 
1313
+ test('4b-2 V9:成员的 admin 路由把提案/投票转给 hub(身份只认签名;hub 独有路径仍 403)', async (t) => {
1314
+ // V7 走的是**群级 HTTP**(groupCall 直连 hub);这里走的是**成员自己的管理面板**:
1315
+ // 成员的 /api/lan-peers/group/proposal|vote 必须把输入转给 hub(memberVoteCall → groupCall),
1316
+ // 本机照旧只读。这条缝以前只有桩(lib/admin-group-vote.test.mjs),坏了在生产里是静默的。
1317
+ const { createAdmin } = await import('../../lib/admin.mjs');
1318
+ // 显式给名字:同机多节点会把 config.name 都回落到 hostname,成员名互相覆盖(作者口径就分不清了)
1319
+ const cluster = await startCluster(t, { count: 2, names: ['hub-box', 'member-box'] });
1320
+ const [hub, member] = cluster.nodes;
1321
+ const memberAdmin = createAdmin({ server: member.server });
1322
+ const hubVotesFile = path.join(hub.dir, 'group-votes.json');
1323
+ const memberVotesFile = path.join(member.dir, 'group-votes.json');
1324
+ /** hub 记的那个成员名(作者口径的唯一来源) */
1325
+ const nameOnHub = (peerId) => hub.server.group.members().find((entry) => entry.peerId === peerId)?.name ?? '';
1326
+
1327
+ // 1) 成员面板提交「补充」→ 201,body 带回 hub 判的结果 + 本机只读的平面摘要;
1328
+ // 正文里自称的作者是 hub 的名字,**必须被忽略**(作者 = 签名者)。
1329
+ const created = await hitAdmin(memberAdmin, 'POST', '/api/lan-peers/group/proposal', {
1330
+ kind: 'supplement', target: { type: 'body', id: 'n1' }, text: '十月计划再补一条', author: nameOnHub(hub.peerId),
1331
+ });
1332
+ assert.equal(created.status, 201, JSON.stringify(created.json));
1333
+ assert.equal(created.json.ok, true);
1334
+ assert.equal(created.json.proposal.kind, 'supplement');
1335
+ assert.equal(created.json.status, 'adopted', '补充默认采纳:这是 hub 判的,不是成员猜的');
1336
+ assert.equal(created.json.vote, null);
1337
+ // 摘要来自本机副本(成员只读,此刻还没 pull),但两个键与计数必须都在且自洽
1338
+ assert.equal(created.json.votePlane.rev, member.server.group.votes().rev, 'body 里的平面是成员副本');
1339
+ assert.equal(created.json.voteSummary.rev, created.json.votePlane.rev);
1340
+ assert.equal(created.json.voteSummary.proposals, created.json.votePlane.proposals.length);
1341
+ // 权威文件:hub 那份有了,成员那份**根本没有**
1342
+ assert.equal(fs.existsSync(hubVotesFile), true, 'hub 有自己的表决平面文件');
1343
+ assert.equal(fs.existsSync(memberVotesFile), false, '成员的提交没有在本机写权威文件');
1344
+ const hubPlane = readJson(hubVotesFile);
1345
+ const supplementId = created.json.proposal.id;
1346
+ assert.equal(hubPlane.proposals.length, 1, JSON.stringify(hubPlane.proposals));
1347
+ assert.equal(hubPlane.proposals[0].id, supplementId, 'body 里回的 id 就是 hub 落盘的那条');
1348
+ assert.equal(hubPlane.proposals[0].author, nameOnHub(member.peerId), '作者是签名者,body 里自称的 hub 名字不算数');
1349
+
1350
+ // 2) 成员面板提交「反对」→ hub 自动开票(第 1 轮),body 带回那一轮
1351
+ const objection = await hitAdmin(memberAdmin, 'POST', '/api/lan-peers/group/proposal', {
1352
+ kind: 'objection', target: { type: 'supplement', id: supplementId }, text: '这条我不认同',
1353
+ });
1354
+ assert.equal(objection.status, 201, JSON.stringify(objection.json));
1355
+ assert.equal(objection.json.status, 'open', '反对一到 hub 就开票');
1356
+ assert.equal(objection.json.vote.round, 1);
1357
+ assert.equal(objection.json.vote.status, 'open');
1358
+ const voteId = objection.json.proposal.id;
1359
+ assert.equal(hub.server.group.votes().votes[voteId].status, 'open', '开轮发生在 hub,不在成员');
1360
+ assert.equal(hub.server.group.voteTimers(), 1, 'hub 侧恰好挂一个定时器');
1361
+
1362
+ // 3) 成员面板投票 → 200 + accepted:true,票落在 hub 的台账里
1363
+ const cast = await hitAdmin(memberAdmin, 'POST', '/api/lan-peers/group/vote', { proposalId: voteId, ballot: 'yes' });
1364
+ assert.equal(cast.status, 200, JSON.stringify(cast.json));
1365
+ assert.equal(cast.json.accepted, true, '这一票必须被收下:' + JSON.stringify(cast.json));
1366
+ assert.equal(cast.json.reason, '');
1367
+ assert.equal(hub.server.group.votes().votes[voteId].ballots[member.peerId], 'yes', '票记在签名者名下');
1368
+
1369
+ // 4) 身份不可冒名:body 里自称是 hub 的人 → hub 侧的记录仍然是签名者
1370
+ const spoofed = await hitAdmin(memberAdmin, 'POST', '/api/lan-peers/group/vote', {
1371
+ proposalId: voteId, ballot: 'yes', peerId: hub.peerId, author: nameOnHub(hub.peerId), senderId: hub.peerId,
1372
+ });
1373
+ assert.equal(spoofed.status, 200, JSON.stringify(spoofed.json));
1374
+ assert.equal(spoofed.json.accepted, true);
1375
+ const ballots = hub.server.group.votes().votes[voteId].ballots;
1376
+ assert.deepEqual(Object.keys(ballots), [member.peerId], 'body 里自称的 peerId 一个都不认(也绝不留下第二个投票人)');
1377
+ assert.equal(ballots[hub.peerId], undefined, '冒名 hub 的那一票不许记到 hub 名下');
1378
+ assert.equal(ballots[member.peerId], 'yes');
1379
+
1380
+ // 5) hub 独有路径(人裁决):成员面板照样 403 votes-hub-only,且 hub 平面一点不动
1381
+ const revBefore = hub.server.group.votes().rev;
1382
+ const refused = await hitAdmin(memberAdmin, 'POST', '/api/lan-peers/group/adjudicate', {
1383
+ proposalId: voteId, decision: 'adopt',
1384
+ });
1385
+ assert.equal(refused.status, 403, JSON.stringify(refused.json));
1386
+ assert.deepEqual(refused.json, { ok: false, error: 'votes-hub-only' });
1387
+ assert.equal(hub.server.group.votes().rev, revBefore, '被拒的那次没有动 hub 平面');
1388
+
1389
+ // 6) 副本收敛:一次 pull 之后成员副本的 rev 与 hub 相等,提案/票都在副本里
1390
+ const pulled = await pullNow(member);
1391
+ assert.equal(pulled?.ok, true, '成员这一轮 pull 必须真跑起来:' + JSON.stringify(pulled));
1392
+ const hubRev = hub.server.group.votes().rev;
1393
+ const replicaPlane = member.server.group.votes();
1394
+ assert.ok(hubRev >= 2, '平面 rev 至少被上面几次输入推进过:' + String(hubRev));
1395
+ assert.equal(replicaPlane.rev, hubRev, '成员副本停在与 hub 同一份 rev 上');
1396
+ assert.deepEqual(replicaPlane.proposals.map((entry) => entry.id), [supplementId, voteId]);
1397
+ assert.equal(replicaPlane.votes[voteId].ballots[member.peerId], 'yes', '经成员面板投的那一票同步得回来');
1398
+ assert.equal(fs.existsSync(memberVotesFile), false, '成员侧始终没有权威文件的写路径');
1399
+ // 「更新的摘要」是从这条既有的只读读口拿到的:成员 GET group 与 hub 同步
1400
+ const state = await hitAdmin(memberAdmin, 'GET', '/api/lan-peers/group?sinceSeq=0');
1401
+ assert.equal(state.json.voteSummary.rev, hubRev, JSON.stringify(state.json.voteSummary));
1402
+ assert.equal(state.json.voteSummary.proposals, 2);
1403
+ assert.ok(state.json.votePlane.proposals.some((entry) => entry.id === voteId));
1404
+ });
1405
+
1313
1406
  test('4b-1 V8:一轮一个定时器;关轮与 close() 都清掉(进程能自己退出)', async (t) => {
1314
1407
  const lines = [];
1315
1408
  // 轮时长压到 400ms:既不真等 10 分钟,又给同步断言留足余量(不会和定时器抢跑)
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 0.10.0-α slice 4b-3 · shelved 的**提醒**(只提醒,不裁决)。
3
+ *
4
+ * 症状:提案第 3 轮还没过就 shelved,而它唯一的出口是面板的群页签 —— 从不打开那个页签的
5
+ * 运维者永远不知道有决定在等他。这片补的是一条**声音**,状态机一个字都没动:
6
+ * T1 搁置当场发且只发一条 kind:'system',正文/备注不进群历史;
7
+ * T2 同一窗口内重复触发不刷屏(幂等);
8
+ * T2b 提醒确实由定时器发出来(不是只在搁置那一下发过一次);
9
+ * T2c 隔着两个整间隔各发一条(节奏会重复,不是发一次就停,第二次之后仍挂着);
10
+ * T3 已裁决的提案不再提醒,定时器被清掉;
11
+ * T4 close() 之后两种定时器都是 0(进程因此能退出:跑完这个文件就是证据);
12
+ * T5 重启后 resume 重新挂上提醒(只断言挂着,不等它到点);
13
+ * T6 没有新的 console.error;日志只有 id(≤8)+ 轮数,没有正文。
14
+ */
15
+ import test from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { setTimeout as sleep } from 'node:timers/promises';
20
+ import { listenOnRandomPort } from '../../test-support/ports.mjs';
21
+ import { createPeerServer } from '../server.mjs';
22
+ import { makeTempStateDir, readJson } from './helpers.mjs';
23
+
24
+ const PROPOSAL_BODY = 'PROPOSAL-BODY-SECRET-7f3a';
25
+ const NOTE_BODY = 'NOTE-BODY-SECRET-9c1d';
26
+
27
+ /** hub:stateDir 由调用方持有(T5 要「杀掉再重启」同一个目录) */
28
+ async function startHub(t, { stateDir = makeTempStateDir(), logger, reminderMs } = {}) {
29
+ const server = createPeerServer({
30
+ listenHost: '127.0.0.1',
31
+ stateDir,
32
+ requireEncryption: false,
33
+ // quorum:none = 写本地即提交:这里测的是提醒,不是复制
34
+ group: { enabled: true, name: 'default', autoJoin: true, retentionDays: 7, quorum: 'none' },
35
+ ...(logger ? { logger } : {}),
36
+ ...(reminderMs ? { groupVote: { reminderMs } } : {}),
37
+ });
38
+ const info = await listenOnRandomPort(server);
39
+ t.after(async () => {
40
+ await server.close();
41
+ fs.rmSync(stateDir, { recursive: true, force: true });
42
+ });
43
+ return { server, baseUrl: info.url, stateDir, group: server.group };
44
+ }
45
+
46
+ /** 群历史里的 system 消息(读权威文件,不读渲染后的视图:断言的是**落盘**的东西) */
47
+ function systemMessages(stateDir) {
48
+ const data = readJson(path.join(stateDir, 'group.json'));
49
+ return (data.messages ?? []).filter((message) => message.kind === 'system');
50
+ }
51
+
52
+ /**
53
+ * 手动走完三轮把它顶到 shelved:每轮都投 no。
54
+ * 名单只有 hub 自己(这条用例不起成员)→ 一轮立刻 impossible → failed → 下一轮,绝不用真等 10 分钟。
55
+ */
56
+ function shelveOnce(group, { note = false } = {}) {
57
+ if (note) group.appendNote({ text: NOTE_BODY });
58
+ const submitted = group.submitProposal({
59
+ kind: 'objection',
60
+ target: { type: 'body', id: 'body-1' },
61
+ text: PROPOSAL_BODY,
62
+ author: 'alice',
63
+ });
64
+ assert.equal(submitted.ok, true, '提案要能入库:' + JSON.stringify(submitted));
65
+ const id = submitted.proposal.id;
66
+ for (let round = 1; round <= 3; round += 1) {
67
+ const vote = group.votes().votes[id];
68
+ assert.equal(vote.status, 'open', '第 ' + round + ' 轮要开着:' + JSON.stringify(vote));
69
+ const cast = group.castVote({ proposalId: id, peerId: vote.roster[0], ballot: 'no' });
70
+ assert.equal(cast.closed, true, 'no 票让第 ' + round + ' 轮当场结算(impossible)');
71
+ }
72
+ assert.equal(group.votes().votes[id].status, 'shelved');
73
+ return id;
74
+ }
75
+
76
+ test('4b-3 T1:搁置当场只发一条 system 提醒,且正文/备注一个字都不进群历史', async (t) => {
77
+ const lines = [];
78
+ const hub = await startHub(t, { logger: (line) => lines.push(String(line)) });
79
+ const id = shelveOnce(hub.group, { note: true });
80
+
81
+ assert.deepEqual(hub.group.shelved().map((entry) => entry.proposalId), [id], 'shelved 集里有它');
82
+ const systems = systemMessages(hub.stateDir);
83
+ assert.equal(systems.length, 1, '恰好一条 system(不是 0 条,也不是两条):'
84
+ + JSON.stringify(systems.map((entry) => entry.text)));
85
+ const text = systems[0].text;
86
+ assert.match(text, /shelved/, '要带 shelved 标记:' + text);
87
+ assert.match(text, /kind=objection/, '要带提案种类:' + text);
88
+ assert.match(text, /rounds=3/, '要带轮数 3:' + text);
89
+ assert.match(text, /hub operator/, '要说清只有 hub 运维者能裁决:' + text);
90
+ assert.equal(text.includes(PROPOSAL_BODY), false, '提案正文不进群历史');
91
+ assert.equal(text.includes(NOTE_BODY), false, '备注正文不进群历史');
92
+ // 既有契约不变:轮定时器 0(shelved 之后不许再留),提醒另有一张表
93
+ assert.equal(hub.group.voteTimers(), 0, 'shelved 之后没有轮定时器');
94
+ assert.equal(hub.group.shelvedReminders(), 1, '挂着恰好一个重提醒');
95
+ assert.ok(lines.length > 0);
96
+ });
97
+
98
+ test('4b-3 T2:同一窗口内重复触发不刷屏(幂等),窗口过去后一条一次', async (t) => {
99
+ const hub = await startHub(t, { logger: () => {} });
100
+ const id = shelveOnce(hub.group);
101
+ const before = systemMessages(hub.stateDir);
102
+ assert.equal(before.length, 1);
103
+
104
+ // 1) 重放一次 closeVote(状态已不是 open)→ 不推进、不发消息
105
+ const replay = hub.group.closeVote(id);
106
+ assert.equal(replay.already, true, '重复结算原样返回:' + JSON.stringify(replay));
107
+ // 2) 同一窗口内连叫两次提醒 → 都被窗口闸挡掉
108
+ assert.equal(hub.group.remindShelved(id).emitted, false, '窗口内的第二次触发是空操作');
109
+ assert.equal(hub.group.remindShelved(id).emitted, false, '再叫一次还是空操作');
110
+ assert.equal(systemMessages(hub.stateDir).length, 1, '群历史里还是只有那一条');
111
+ assert.equal(hub.group.shelvedReminders(), 1, '定时器没被叠成两个');
112
+ });
113
+
114
+ test('4b-3 T2b:提醒真的由定时器发出来(间隔可覆盖,断言两条同形)', async (t) => {
115
+ const hub = await startHub(t, { logger: () => {}, reminderMs: 120 });
116
+ shelveOnce(hub.group);
117
+ const texts = [];
118
+ for (let index = 0; index < 100 && texts.length < 2; index += 1) {
119
+ await sleep(20);
120
+ const systems = systemMessages(hub.stateDir);
121
+ if (systems.length >= 2) texts.push(...systems.map((entry) => entry.text));
122
+ }
123
+ assert.equal(texts.length >= 2, true, '到点必须自己再发一条(这正是「不会沉默」):' + texts.length);
124
+ assert.equal(texts[1], texts[0], '提醒与搁置公告同形(同一条文本,不夹带新信息)');
125
+ });
126
+
127
+ test('4b-3 T2c:隔着两个整间隔各发一条(节奏会重复,不是发一次就停)', async (t) => {
128
+ const INTERVAL_MS = 120;
129
+ const lines = [];
130
+ const hub = await startHub(t, { logger: (line) => lines.push(String(line)), reminderMs: INTERVAL_MS });
131
+ shelveOnce(hub.group);
132
+ assert.equal(systemMessages(hub.stateDir).length, 1, '起点只有搁置当场那一条(T1)');
133
+ assert.equal(hub.group.shelvedReminders(), 1, '起点挂着恰好一个提醒');
134
+
135
+ // 按「落盘条数变没变」取样,不看定时器内部怎么实现;记下每次变化的相对时刻
136
+ const startedAt = performance.now();
137
+ const changes = [];
138
+ let count = 1;
139
+ while (count < 3 && performance.now() - startedAt < 2000) {
140
+ await sleep(10);
141
+ const now = systemMessages(hub.stateDir).length;
142
+ if (now !== count) {
143
+ changes.push({ at: performance.now() - startedAt, count: now });
144
+ count = now;
145
+ }
146
+ }
147
+ // 第二条之后**立刻**读:第三条还要再过一整个间隔,此刻必须还挂着
148
+ const armedAfterSecond = hub.group.shelvedReminders();
149
+
150
+ assert.deepEqual(changes.map((change) => change.count), [2, 3],
151
+ '两个间隔各来一条(1→2→3:一次不落,也不在一个间隔里连发):' + JSON.stringify(changes));
152
+ assert.ok(changes[0].at >= INTERVAL_MS - 20,
153
+ '第一条要等满第一个间隔才到:' + changes[0].at.toFixed(0) + 'ms');
154
+ assert.ok(changes[1].at - changes[0].at >= INTERVAL_MS - 20,
155
+ '第二条要再等一个整间隔(不是紧挨着第一条,中间不许有):'
156
+ + (changes[1].at - changes[0].at).toFixed(0) + 'ms');
157
+ assert.equal(armedAfterSecond, 1,
158
+ '第二条之后定时器仍挂着 ——「发一次就停」的实现在这里是 0,第三个间隔永远不会来');
159
+ const systems = systemMessages(hub.stateDir);
160
+ assert.equal(systems.length, 3, '落盘 3 条 = 1 条搁置公告 + 2 条重提醒(不是 2 条,也不是 4 条)');
161
+ assert.equal(systems[1].text, systems[0].text, '第一条重提醒与搁置公告逐字节相同');
162
+ assert.equal(systems[2].text, systems[0].text, '第二条重提醒与前一条逐字节相同(文本不随次数漂移)');
163
+ const reminderLogs = lines.filter((line) => line.includes('vote shelved reminder'));
164
+ assert.equal(reminderLogs.length, 3, '日志口径同为 1+2 行:' + JSON.stringify(reminderLogs));
165
+ });
166
+
167
+ test('4b-3 T3:已裁决的提案不再提醒,且定时器被清掉', async (t) => {
168
+ const hub = await startHub(t, { logger: () => {} });
169
+ const id = shelveOnce(hub.group);
170
+ assert.equal(hub.group.shelvedReminders(), 1);
171
+ const decided = hub.group.adjudicate({ proposalId: id, decision: 'reject', reason: '不用了', by: 'ops' });
172
+ assert.equal(decided.ok, true, '裁决要能落地:' + JSON.stringify(decided));
173
+ assert.equal(hub.group.shelvedReminders(), 0, '裁决 = 不再等任何人 → 提醒定时器清零');
174
+ assert.equal(hub.group.remindShelved(id).emitted, false, '已裁决的提案不发提醒');
175
+ assert.equal(systemMessages(hub.stateDir).length, 1, '只有搁置那一条,裁决不再发第二条');
176
+ });
177
+
178
+ test('4b-3 T4:close() 之后两种定时器都是 0(进程能退出)', async (t) => {
179
+ const hub = await startHub(t, { logger: () => {}, reminderMs: 60 });
180
+ shelveOnce(hub.group);
181
+ assert.equal(hub.group.shelvedReminders(), 1);
182
+ await hub.server.close();
183
+ assert.equal(hub.group.shelvedReminders(), 0, 'close() 必须把提醒整表清掉');
184
+ assert.equal(hub.group.voteTimers(), 0, '轮定时器同理');
185
+ });
186
+
187
+ test('4b-3 T5:重启后 resume 重新挂上提醒(不立刻补发)', async (t) => {
188
+ const stateDir = makeTempStateDir();
189
+ const first = await startHub(t, { stateDir, logger: () => {} });
190
+ const id = shelveOnce(first.group);
191
+ assert.equal(first.group.shelvedReminders(), 1);
192
+ await first.server.close();
193
+ assert.equal(first.group.shelvedReminders(), 0);
194
+
195
+ const second = await startHub(t, { stateDir, logger: () => {} });
196
+ assert.deepEqual(second.group.shelved().map((entry) => entry.proposalId), [id], '重启后 shelved 仍在');
197
+ assert.equal(second.group.shelvedReminders(), 1, 'listen 的 resume 必须把提醒重新挂上');
198
+ assert.equal(systemMessages(stateDir).length, 1, '重启不补发(补发会让反复重启变成刷屏)');
199
+ });
200
+
201
+ test('4b-3 T6:没有新的 console.error;日志只有 ≤8 的 id 与轮数,没有正文', async (t) => {
202
+ const lines = [];
203
+ const errors = [];
204
+ const realError = console.error;
205
+ console.error = (...args) => { errors.push(args.map((arg) => String(arg)).join(' ')); };
206
+ t.after(() => { console.error = realError; });
207
+ try {
208
+ const hub = await startHub(t, { logger: (line) => lines.push(String(line)) });
209
+ shelveOnce(hub.group, { note: true });
210
+ } finally {
211
+ console.error = realError;
212
+ }
213
+ assert.deepEqual(errors, [], '不许出现新的 console.error');
214
+ const reminders = lines.filter((line) => line.includes('vote shelved reminder'));
215
+ assert.equal(reminders.length, 1, '一次提醒恰好一行日志:' + JSON.stringify(reminders));
216
+ assert.match(reminders[0], /^\[group\] vote shelved reminder id=\S{1,8} round=3$/, '格式与隐私:' + reminders[0]);
217
+ for (const line of lines) {
218
+ assert.equal(line.includes(PROPOSAL_BODY), false, '日志里没有提案正文:' + line);
219
+ assert.equal(line.includes(NOTE_BODY), false, '日志里没有备注正文:' + line);
220
+ }
221
+ });
package/lib/i18n.mjs CHANGED
@@ -228,8 +228,6 @@ const CLI_TEXT = {
228
228
  'cli.group.badKind': '--kind 必须是 {kinds}',
229
229
  'cli.group.sent': '已发送 seq={seq} ts={ts}{channel}',
230
230
  'cli.group.sentViaGroupKey': '(群级签名直连 {baseUrl})',
231
- 'cli.group.acceptOn': '接单中',
232
- 'cli.group.acceptOff': '已关闭',
233
231
 
234
232
  // ---- group:history / tail / join ----
235
233
  'cli.group.historyPeer': '群 {groupId} · {count} 条 · lastSeq={lastSeq}',
@@ -582,8 +580,6 @@ const CLI_TEXT_EN = {
582
580
  'cli.group.badKind': '--kind must be one of {kinds}',
583
581
  'cli.group.sent': 'sent seq={seq} ts={ts}{channel}',
584
582
  'cli.group.sentViaGroupKey': ' (group signature direct to {baseUrl})',
585
- 'cli.group.acceptOn': 'accepting',
586
- 'cli.group.acceptOff': 'off',
587
583
 
588
584
  'cli.group.historyPeer': 'group {groupId} · {count} messages · lastSeq={lastSeq}',
589
585
  'cli.group.historyKey': 'group {groupId} · {count} messages · lastSeq={lastSeq} (group signature direct to {baseUrl})',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lan-peers",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "局域网 DSH 协同:一次性配对互认、HMAC 签名 + AES-256-GCM 加密、agent 互派任务",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",