@twinsearth/dsh-master 3.3.0 → 3.3.2

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.
@@ -0,0 +1,180 @@
1
+ # M5 · 瓮城回流接口实测报告(contract 1.1.0 / 房主 3.3.1)
2
+
3
+ > 结论:**M5 达标(done)**,但证据的**对端身份是 `contract-server`**(瓮城侧契约 SDK 的独立进程),
4
+ > 不是 `dsh-fang-bundle` 本体。这个区别写在里程碑结果行的 `notes` 里,不隐藏。
5
+ >
6
+ > 日期:2026-11-13 · 环境:Windows / Node 26.13.0 / 单机双进程
7
+
8
+ ## 1. 为什么需要这一轮
9
+
10
+ 3.3.0 的 M5 只差一件事:`与瓮城回流对接`。当时的实现是**硬编码**
11
+
12
+ ```js
13
+ relay: { target: 'fang', available: false, reason: 'fang-channel-not-ready' }
14
+ ```
15
+
16
+ 接口位在,但没有协议、没有投递、没有回执。contract 1.0.0 的方法表里也确实没有任何
17
+ 回流方法(只有 `fang.fork.*` / `fang.decision.*` / `fang.session.list` / `fang.orchestrator.status`),
18
+ 所以“接上”只能先把接口版本升上去。
19
+
20
+ ## 2. 这一轮改了什么
21
+
22
+ ### 2.1 接口包 `@twinsearth/dsh-fang-contract@1.1.0`
23
+
24
+ | 变化 | 内容 |
25
+ |---|---|
26
+ | 新方法 | `fang.relay.publish`(批量投递,按 `event.id` 幂等,返回回执 + 游标)、`fang.relay.status`(对端自报 peer/cursor/累计计数) |
27
+ | 新能力 | `event-relay` |
28
+ | 参考实现 | `createRelayStore` / `validateRelayEvents` / `relayHandlers` / `createRelayContractServer` |
29
+ | 客户端 | `publishRelay()` / `relayStatus()` |
30
+ | 传输加固 | 逐调用 `timeoutMs`/`meta` 透传、`httpHeaders` 透传、HTTP 定时器 `unref`、file 回执/请求文件有界重试清理 |
31
+ | 兼容 | 只升 MINOR;1.0 对端不会宣告 1.1 方法,`negotiate()` 按公共 minor 自动裁剪 |
32
+
33
+ 发布核验:npm `1.1.0` = `latest`,下载包与本地 tarball **字节一致**
34
+ (sha256 `e5224dc682b7a66247cd100d47492047b081acf233fd1f7daac4fab6753d5bd4`,11 个文件)。
35
+ 仓库 main 已推到 `c106053d`(tree 与本地一致)。
36
+
37
+ ### 2.2 房主侧
38
+
39
+ - `src/contract/` 内嵌副本同步到 1.1.0,与已发布包**逐字节相同**(`test/contract-conformance.test.mjs` 守卫);
40
+ - 新增 `src/cost/fang-relay.ts`:`probe()` 判定对端是否真的提供回流;`enqueue()/flush()`
41
+ 批量投递且**只认回执**;失败退回队列并转 `publish-failed:*`;
42
+ - `cost-optimizer` / `superagent-router` / `createCostSystem()` 全部改为读真实状态;
43
+ - M5 判定收紧为 `available + peer 白名单 + events ≥ 3 + 非自环`。
44
+
45
+ ## 3. 实测方法(可复现)
46
+
47
+ ```bash
48
+ npm run build
49
+ node scripts/fang-relay-probe.mjs --events 6 --out relay-evidence.json
50
+ ```
51
+
52
+ 脚本行为:
53
+
54
+ 1. 起一个**独立子进程**(`--serve`),里面跑**已发布的 npm 包**
55
+ `@twinsearth/dsh-fang-contract@1.1.0` 的 `createRelayContractServer`,
56
+ 以 HTTP 暴露 `/fang/api/contract/{hello,invoke,health}`(Bearer token,不裸奔);
57
+ 2. 父进程用仓库内嵌副本作客户端,`probe()` → 入队 6 条事件(三类事件混合)→ `flush()`;
58
+ 3. 只把对端回执当证据;`sameProcess` 由两侧 pid 比较得出;
59
+ 4. 证据 JSON 顶层就是 `{ ok, fangRelay, detail }`,可直接喂给 `scripts/milestones.mjs --input`。
60
+
61
+ ## 4. 实测结果
62
+
63
+ ```json
64
+ {
65
+ "ok": true,
66
+ "fangRelay": {
67
+ "available": true,
68
+ "peer": "contract-server",
69
+ "events": 6,
70
+ "sameProcess": false,
71
+ "transport": "http",
72
+ "contractVersion": "1.1.0",
73
+ "reason": null,
74
+ "cursor": 6,
75
+ "failed": 0,
76
+ "dropped": 0
77
+ },
78
+ "detail": {
79
+ "probePid": 13340,
80
+ "peerPid": 4476,
81
+ "endpoint": "http://127.0.0.1:58328",
82
+ "batches": [{ "ok": true, "delivered": 6, "duplicates": 0, "sent": 6, "queued": 0, "cursor": 6, "batchId": "relay-1" }]
83
+ }
84
+ }
85
+ ```
86
+
87
+ 里程碑输出:
88
+
89
+ ```text
90
+ M5 │ 实现 ✔ │ 依赖 ✔ │ 效果 verified │ 状态 done │ 回流对端 'contract-server·6条'
91
+ M5 说明: peer=contract-server:契约对端独立进程实测,dsh-fang-bundle 采用 contract 1.1 后应变为 peer=dsh-fang-bundle
92
+ ```
93
+
94
+ ## 5. 能证明 / 不能证明
95
+
96
+ **能证明**
97
+
98
+ - contract 1.1 的回流协议在**两个真实进程**之间跑通:hello 协商 → 方法裁剪 → 投递 → 回执 → 游标;
99
+ - 幂等成立:同 id 重投 `accepted:0 / duplicates>0`,游标不倒退;
100
+ - 失败路径不伪装:客户端不可用 / 对端 1.0 / hello 失败 / status 失败 / publish 失败,
101
+ 五种情况分别有具名原因,且失败批次会退回队列并计入 `failed`(有单元测试);
102
+ - 房主内嵌副本与已发布包无漂移(逐字节 + 双向互操作测试)。
103
+
104
+ **不能证明**
105
+
106
+ - 不能说“两个插件在同一个 DSH 宿主里同时加载跑通了”:按本会话守则 §二,**不得把瓮城安装/升级到任何 profile**,
107
+ 所以本轮没有做宿主级 co-load;做的是「瓮城**已发布包**的 vendored 契约服务端 × 房主内嵌副本客户端」的跨进程实测;
108
+ - 不能证明生产环境的网络/鉴权拓扑:本轮是 127.0.0.1 + Bearer token 的本机双进程。
109
+
110
+ ## 6. 复现注意事项
111
+
112
+ - 必须 `npm run build` 后跑:脚本 import 的是 `dist-host/`;
113
+ - 两种对端:默认 `--peer contract` 用已发布的契约包(自报 `contract-server`,仅协议自测);
114
+ `--peer fang --fang-dir <解包目录>` 用**瓮城自己的包**当对端(自报 `dsh-fang-bundle`);
115
+ - 证据文件不要提交进仓库(G6 零残留门禁会检查 `rsi-report.json` 一类产物);
116
+ - Windows 上子进程退出由 `SIGTERM` 处理,若端口被占用会直接报 ready 超时。
117
+
118
+ ## 7. 瓮城 3.1.35 复核(2026-11-13 晚)
119
+
120
+ 瓮城已发 **3.1.35**(commit `feat(v3.1.35): 适配 contract 1.1.0 回流接口 (#100)`),房主侧按下面的方式复核:
121
+
122
+ ```bash
123
+ npm run build
124
+ node scripts/fang-relay-probe.mjs --peer fang --fang-dir <解包后的 @twinsearth/dsh-fang> --events 6
125
+ ```
126
+
127
+ **静态核对(对端是瓮城发布物本身)**
128
+
129
+ | 项 | 结果 |
130
+ |---|---|
131
+ | 瓮城 `src/contract/` 8 个文件 vs contract 1.1.0 | **8/8 逐字节相同** |
132
+ | 瓮城 `src/contract/VENDOR.json` | `package=@twinsearth/dsh-fang-contract, version=1.1.0, fileCount=8`,逐文件 sha256 与实物一致 |
133
+ | `src/plugins/orchestrator.mjs` | `createRelayStore({ peerLabel: 'dsh-fang-bundle' })` + handlers 里 `...relayHandlers(relayStore)` |
134
+
135
+ 探针不硬编码 `peerLabel`:它从**瓮城自己的 orchestrator 源码**里读出该值,并用瓮城的 `VENDOR.json` 校验文件哈希,
136
+ 所以 `peer` 是瓮城自报的,不是我们假设的。
137
+
138
+ **实测结果(`--peer fang`)**
139
+
140
+ ```json
141
+ {
142
+ "ok": true,
143
+ "peerMode": "fang",
144
+ "expectation": { "peer": "dsh-fang-bundle", "matched": true },
145
+ "fangRelay": { "available": true, "peer": "dsh-fang-bundle", "events": 6,
146
+ "sameProcess": false, "transport": "http", "contractVersion": "1.1.0",
147
+ "cursor": 6, "failed": 0, "dropped": 0 },
148
+ "provenance": { "fangVersion": "3.1.35", "vendorVersion": "1.1.0",
149
+ "vendoredFilesChecked": 8, "vendoredFilesOk": true,
150
+ "peerLabelSource": "src/plugins/orchestrator.mjs",
151
+ "relayHandlersRegistered": true },
152
+ "detail": {
153
+ "probePid": 10632, "peerPid": 12972, "sameProcess": false,
154
+ "idempotency": { "ok": true, "events": 3,
155
+ "first": { "accepted": 3, "duplicates": 0, "cursor": 9 },
156
+ "second": { "accepted": 0, "duplicates": 3, "cursor": 9 } }
157
+ }
158
+ }
159
+ ```
160
+
161
+ 四项预期全部成立:`peer === 'dsh-fang-bundle'`、`events ≥ 3`、`sameProcess === false`、`failed === 0`;
162
+ 额外核对幂等重投:同批事件第二次 `accepted:0 / duplicates:3 / cursor` 不倒退。
163
+
164
+ **这次实测发现 contract 1.1.0 的一个回执缺陷(已在 1.1.1 修复并发布)**
165
+
166
+ `createRelayStore().publish()` 用 `validated.rejected.length - duplicates` 计算 `rejected`,
167
+ 而 `duplicates` 同时包含「已存在重复」和「批内重复」,重投时会得到**负数**(实测 `-3`)。
168
+ 影响面:只影响回执里的 `rejected` 字段(事件去重、`cursor`、`accepted` 都正确),房主侧不读该字段;
169
+ 但负数回执是错的数据。已加回归测试并修正为「`rejected` 只数真正非法条目,`duplicates` 单独累计」,
170
+ 契约仓库测试 16/16 通过;**contract 1.1.1 已发布**(npm `latest=1.1.1`,字节核验通过),
171
+ 房主 **3.3.2** 已同步 1.1.1 并重新三方发布。
172
+
173
+ > 注意:瓮城 3.1.35 内嵌的仍是 1.1.0(含该回执缺陷)。这是 PATCH 级差异、只影响 `rejected` 显示,
174
+ > 不影响去重与投递;瓮城下次 vendor 同步时带上 1.1.1 即可,无需协议改动。
175
+
176
+ **仍未证明**
177
+
178
+ - 宿主级 co-load(守则 §二禁止往 profile 装瓮城);
179
+ - 瓮城侧 UI/信息栏是否消费了回流事件(属瓮城产品面,房主只需协议可用)。
180
+
@@ -78,5 +78,10 @@ node scripts/fork-experiment.mjs --real --tasks 20 --out /tmp/real-ab.json --jso
78
78
  M5 implemented-effect-unmeasured(原因:fang-relay-out-of-scope)
79
79
  ```
80
80
 
81
- M5 唯一未达标项是「与瓮城回流对接」—— 该通道按本会话守则(§二)不可用,
81
+ M5 当时唯一未达标项是「与瓮城回流对接」—— 该通道当时没有协议实现,
82
82
  接口位显式返回 `{ available:false, reason:'fang-channel-not-ready' }`,不伪装成功。
83
+
84
+ > **后续(3.3.1 已收口)**:contract 1.1.0 新增 `fang.relay.publish` / `fang.relay.status`,
85
+ > 房主侧新增 `src/cost/fang-relay.ts` 真实投递,并用独立进程对端拿到 6 条回执;
86
+ > M5 现在 `done`(`peer=contract-server`)。详见
87
+ > [`EXPERIMENT-M5-fang-relay.md`](EXPERIMENT-M5-fang-relay.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-master",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -75,7 +75,7 @@
75
75
  "build": "npm run build:host && npm run build:worker"
76
76
  },
77
77
  "devDependencies": {
78
- "@twinsearth/dsh-fang-contract": "^1.0.0",
78
+ "@twinsearth/dsh-fang-contract": "^1.1.1",
79
79
  "@types/node": "^24.0.0",
80
80
  "typescript": "^5.9.0"
81
81
  },
@@ -81,6 +81,13 @@ export function createContractClient(options = {}) {
81
81
  async resolveDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_RESOLVE, params, invokeOptions) },
82
82
  async listSessions(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.SESSION_LIST, params, invokeOptions) },
83
83
  async orchestratorStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.ORCHESTRATOR_STATUS, params, invokeOptions) },
84
+ /** contract 1.1:批量投递回流事件(按 event.id 幂等;返回回执与游标)。 */
85
+ async publishRelay(events, invokeOptions = {}) {
86
+ const params = Array.isArray(events) ? { events } : (events && typeof events === 'object' ? events : { events: [] })
87
+ return await client.invoke(METHODS.RELAY_PUBLISH, params, invokeOptions)
88
+ },
89
+ /** contract 1.1:读取对端回流状态(对端自报 peer/cursor/累计计数)。 */
90
+ async relayStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.RELAY_STATUS, params, invokeOptions) },
84
91
  }
85
92
  return client
86
93
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  export const CONTRACT_PACKAGE = '@twinsearth/dsh-fang-contract'
14
- export const CONTRACT_VERSION = '1.0.0'
14
+ export const CONTRACT_VERSION = '1.1.1'
15
15
  export const PROTOCOL = 'fang-master'
16
16
  export const PROTOCOL_VERSION = 1
17
17
  export const MIN_COMPATIBLE_MAJOR = 1
@@ -27,6 +27,8 @@ export const METHODS = Object.freeze({
27
27
  DECISION_RESOLVE: 'fang.decision.resolve',
28
28
  SESSION_LIST: 'fang.session.list',
29
29
  ORCHESTRATOR_STATUS: 'fang.orchestrator.status',
30
+ RELAY_PUBLISH: 'fang.relay.publish',
31
+ RELAY_STATUS: 'fang.relay.status',
30
32
  })
31
33
 
32
34
  /** 方法 → 契约版本(用于协商后的方法裁剪)。 */
@@ -40,10 +42,12 @@ export const METHOD_VERSION = Object.freeze({
40
42
  [METHODS.DECISION_RESOLVE]: '1.0',
41
43
  [METHODS.SESSION_LIST]: '1.0',
42
44
  [METHODS.ORCHESTRATOR_STATUS]: '1.0',
45
+ [METHODS.RELAY_PUBLISH]: '1.1',
46
+ [METHODS.RELAY_STATUS]: '1.1',
43
47
  })
44
48
 
45
49
  export const CAPABILITIES = Object.freeze([
46
- 'sync', 'async', 'idempotent', 'stream-events', 'file-outbox',
50
+ 'sync', 'async', 'idempotent', 'stream-events', 'file-outbox', 'event-relay',
47
51
  ])
48
52
 
49
53
  export const ROLES = Object.freeze(['mother', 'essence', 'fork'])
@@ -77,3 +81,14 @@ export const LEGACY_SERVICE_KEYS = Object.freeze({
77
81
  sessionCatalog: 'fangSessionCatalog',
78
82
  orchestrator: 'fang',
79
83
  })
84
+
85
+ /**
86
+ * 回流接口的硬上限(1.1)。超限是**协议错误**而不是静默截断:
87
+ * 调用方必须自己分批,服务端不得悄悄丢事件(回流要可对账)。
88
+ */
89
+ export const RELAY_LIMITS = Object.freeze({
90
+ maxBatch: 128,
91
+ maxEventBytes: 32_000,
92
+ maxPayloadBytes: 512_000,
93
+ maxRecords: 5_000,
94
+ })
@@ -15,5 +15,6 @@ export * from './constants.js'
15
15
  export * from './envelope.js'
16
16
  export * from './negotiate.js'
17
17
  export * from './server.js'
18
+ export * from './relay.js'
18
19
  export * from './transports.js'
19
20
  export * from './client.js'
@@ -0,0 +1,229 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ /**
5
+ * 回流(relay)接口 —— contract 1.1 新增能力。
6
+ *
7
+ * 背景:房主(dsh-Master)的成本总线产生 `cost:event` / `cost:degraded` /
8
+ * `cost:substitution` 三类事件,需要**单向**投递给瓮城(dsh-Fang)做产物回流与
9
+ * 信息栏展示。1.0 的方法表只有请求-响应式的 fork/decision/session,缺一个批量、
10
+ * 可去重、有回执的事件入口,因此 1.1 追加两个方法:
11
+ *
12
+ * * `fang.relay.publish` —— 批量投递事件,按 `event.id` 幂等去重,返回回执与游标;
13
+ * * `fang.relay.status` —— 对端自报回流状态(自身标识 peerLabel、游标、累计计数)。
14
+ *
15
+ * 兼容规则(README §2):新增方法只升 MINOR;1.0 的对端在 `hello` 协商时不会
16
+ * 宣告这两个方法,方法集被自动裁剪,调用方据此判定“回流不可用”,**不得**把
17
+ * 裁剪当成失败重试。
18
+ *
19
+ * 本文件只做协议与参考实现:不读写磁盘、不联网、不持有任何凭据。
20
+ */
21
+
22
+ import { CONTRACT_VERSION, METHODS, RELAY_LIMITS } from './constants.js'
23
+ import { createContractServer } from './server.js'
24
+
25
+ function nowMs(now) {
26
+ const value = typeof now === 'function' ? now() : Date.now()
27
+ const n = Number(value)
28
+ return Number.isFinite(n) ? n : Date.now()
29
+ }
30
+
31
+ function asLimit(value, fallback) {
32
+ const n = Number(value)
33
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
34
+ }
35
+
36
+ function byteLength(value) {
37
+ try { return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8') } catch { return Number.POSITIVE_INFINITY }
38
+ }
39
+
40
+ /**
41
+ * 校验一批回流事件。逐条判定:合法的接受,非法的计 `rejected` 并给原因,
42
+ * 不因单条坏数据丢掉整批(但整批不可用会抛 `relay-events-invalid`)。
43
+ *
44
+ * @param {unknown} events
45
+ * @param {{maxBatch?:number, maxEventBytes?:number, maxPayloadBytes?:number}} [limits]
46
+ */
47
+ export function validateRelayEvents(events, limits = {}) {
48
+ const maxBatch = asLimit(limits.maxBatch, RELAY_LIMITS.maxBatch)
49
+ const maxEventBytes = asLimit(limits.maxEventBytes, RELAY_LIMITS.maxEventBytes)
50
+ const maxPayloadBytes = asLimit(limits.maxPayloadBytes, RELAY_LIMITS.maxPayloadBytes)
51
+ const errors = []
52
+ if (!Array.isArray(events)) {
53
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-must-be-array'], accepted: [], rejected: [] }
54
+ }
55
+ if (events.length === 0) {
56
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-empty'], accepted: [], rejected: [] }
57
+ }
58
+ if (events.length > maxBatch) {
59
+ return { ok: false, code: 'relay-events-invalid', errors: [`events-too-many(>${maxBatch})`], accepted: [], rejected: [] }
60
+ }
61
+ if (byteLength(events) > maxPayloadBytes) {
62
+ return { ok: false, code: 'relay-events-invalid', errors: [`payload-too-large(>${maxPayloadBytes})`], accepted: [], rejected: [] }
63
+ }
64
+ const accepted = []
65
+ const rejected = []
66
+ const seen = new Set()
67
+ for (const raw of events) {
68
+ const item = raw && typeof raw === 'object' ? raw : null
69
+ if (!item) { rejected.push({ id: null, reason: 'event-not-object' }); continue }
70
+ const id = typeof item.id === 'string' ? item.id.trim() : ''
71
+ if (!id) { rejected.push({ id: null, reason: 'id-required' }); continue }
72
+ if (typeof item.type !== 'string' || item.type.trim() === '') { rejected.push({ id, reason: 'type-required' }); continue }
73
+ if (byteLength(item) > maxEventBytes) { rejected.push({ id, reason: `event-too-large(>${maxEventBytes})` }); continue }
74
+ // 同一批内重复 id 也算重复投递:只接受第一次,其余计 duplicates。
75
+ if (seen.has(id)) { rejected.push({ id, reason: 'duplicate-in-batch' }); continue }
76
+ seen.add(id)
77
+ accepted.push({
78
+ id,
79
+ type: String(item.type),
80
+ at: Number.isFinite(Number(item.at)) ? Number(item.at) : 0,
81
+ payload: item.payload && typeof item.payload === 'object' ? item.payload : {},
82
+ meta: item.meta && typeof item.meta === 'object' ? item.meta : {},
83
+ })
84
+ }
85
+ return { ok: accepted.length > 0 || rejected.length === 0, errors, accepted, rejected }
86
+ }
87
+
88
+ /**
89
+ * 回流接收端(瓮城侧)的参考存储:按 event.id 幂等去重,保留有界回执环。
90
+ *
91
+ * @param {{
92
+ * peerLabel?: string, role?: string, instanceId?: string,
93
+ * maxRecords?: number, now?: () => number, limits?: object,
94
+ * }} [options]
95
+ */
96
+ export function createRelayStore(options = {}) {
97
+ const peerLabel = String(options.peerLabel ?? 'contract-server')
98
+ const role = String(options.role ?? 'mother')
99
+ const instanceId = String(options.instanceId ?? '')
100
+ const maxRecords = asLimit(options.maxRecords, RELAY_LIMITS.maxRecords)
101
+ const limits = { ...RELAY_LIMITS, ...(options.limits && typeof options.limits === 'object' ? options.limits : {}) }
102
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
103
+ const seen = new Map()
104
+ const receipts = []
105
+ const counters = { accepted: 0, duplicates: 0, rejected: 0, batches: 0 }
106
+ let cursor = 0
107
+ let lastAt = 0
108
+ const lastErrors = []
109
+
110
+ function remember(receipt) {
111
+ receipts.push(receipt)
112
+ if (receipts.length > maxRecords) receipts.shift()
113
+ }
114
+
115
+ /** @param {{events?: unknown, batchId?: string, taskId?: string}} [params] */
116
+ function publish(params = {}) {
117
+ const validated = validateRelayEvents(params?.events, limits)
118
+ if (validated.ok === false) {
119
+ const error = new Error(validated.errors.join(', ') || 'relay-events-invalid')
120
+ error.code = 'relay-events-invalid'
121
+ error.retryable = false
122
+ throw error
123
+ }
124
+ counters.batches += 1
125
+ const acceptedIds = []
126
+ let alreadySeen = 0
127
+ let inBatchDuplicates = 0
128
+ let rejectedCount = 0
129
+ for (const event of validated.accepted) {
130
+ if (seen.has(event.id)) { alreadySeen += 1; continue }
131
+ seen.set(event.id, { at: nowMs(now), cursor: cursor + 1 })
132
+ cursor += 1
133
+ counters.accepted += 1
134
+ acceptedIds.push(event.id)
135
+ remember({ id: event.id, type: event.type, at: event.at, cursor })
136
+ lastAt = nowMs(now)
137
+ }
138
+ for (const item of validated.rejected) {
139
+ // 批内重复计入 duplicates;其余非法条目计入 rejected 并留原因(有界)。
140
+ if (item.reason === 'duplicate-in-batch') { inBatchDuplicates += 1; continue }
141
+ rejectedCount += 1
142
+ if (lastErrors.length < 50) lastErrors.push({ id: item.id, reason: item.reason })
143
+ }
144
+ // 两个计数分开累计:duplicates = 已存在 + 批内重复;rejected 只数真正非法的条目。
145
+ // (1.1.0 曾用 `validated.rejected.length - duplicates` 计算 rejected,重投时会得到负数。)
146
+ const duplicates = alreadySeen + inBatchDuplicates
147
+ counters.duplicates += duplicates
148
+ counters.rejected += rejectedCount
149
+ const batchId = String(params?.batchId ?? `batch-${counters.batches}`)
150
+ const receipt = Object.freeze({
151
+ ok: true,
152
+ batchId,
153
+ taskId: params?.taskId === undefined ? null : String(params.taskId),
154
+ accepted: acceptedIds.length,
155
+ duplicates,
156
+ rejected: rejectedCount,
157
+ cursor,
158
+ acceptedIds,
159
+ peer: peerLabel,
160
+ at: nowMs(now),
161
+ })
162
+ if (seen.size > maxRecords * 4) {
163
+ // 幂等窗口有界:按插入顺序淘汰最老的一半,避免长跑进程无界增长。
164
+ const overflow = seen.size - maxRecords * 2
165
+ let removed = 0
166
+ for (const key of seen.keys()) {
167
+ if (removed >= overflow) break
168
+ seen.delete(key)
169
+ removed += 1
170
+ }
171
+ }
172
+ return receipt
173
+ }
174
+
175
+ function status() {
176
+ return Object.freeze({
177
+ ok: true,
178
+ available: true,
179
+ peer: peerLabel,
180
+ role,
181
+ instanceId,
182
+ contractVersion: CONTRACT_VERSION,
183
+ cursor,
184
+ accepted: counters.accepted,
185
+ duplicates: counters.duplicates,
186
+ rejected: counters.rejected,
187
+ batches: counters.batches,
188
+ lastAt,
189
+ limits: { ...limits },
190
+ lastErrors: [...lastErrors],
191
+ })
192
+ }
193
+
194
+ function describe() {
195
+ return Object.freeze({ peerLabel, records: receipts.length, seen: seen.size, ...counters, cursor })
196
+ }
197
+
198
+ return Object.freeze({ publish, status, describe, receipts: () => [...receipts] })
199
+ }
200
+
201
+ /**
202
+ * 把回流 store 包成契约 handler 表(挂到 `createContractServer({handlers})`)。
203
+ * @param {{publish:Function, status:Function}} store
204
+ */
205
+ export function relayHandlers(store) {
206
+ if (!store || typeof store.publish !== 'function' || typeof store.status !== 'function') {
207
+ throw new Error('relayHandlers requires a relay store with publish/status')
208
+ }
209
+ return {
210
+ [METHODS.RELAY_PUBLISH]: (params) => store.publish(params ?? {}),
211
+ [METHODS.RELAY_STATUS]: () => store.status(),
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 便捷构造:带回流能力的契约服务端(瓮城侧挂载用)。
217
+ * `peerLabel` 是**自报**标识,房主侧据此记录“对端是谁”,不参与鉴权。
218
+ */
219
+ export function createRelayContractServer(options = {}) {
220
+ const { peerLabel = 'contract-server', role = 'mother', instanceId = '', now, limits, maxRecords, extraHandlers = {}, ...rest } = options
221
+ const store = createRelayStore({ peerLabel, role, instanceId, now, limits, maxRecords })
222
+ const server = createContractServer({
223
+ role,
224
+ instanceId,
225
+ handlers: { ...relayHandlers(store), ...(extraHandlers && typeof extraHandlers === 'object' ? extraHandlers : {}) },
226
+ ...rest,
227
+ })
228
+ return { server, store }
229
+ }