@twinsearth/dsh-master 3.3.0 → 3.3.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/README.md CHANGED
@@ -92,7 +92,8 @@ dsh plugin add <package> # 加载 dist-host/dist-worker
92
92
 
93
93
  ## 状态
94
94
 
95
- - 3.3.0 M1+M2(dev,未发布):成本优化系统层(ledger / budget / policy / audit)+ Worker 四个成本切点(observe-only)+ A1 工具输出紧凑化(默认 shadow,enforce 需白名单);A2–A3 与决策复用 B1–B4 在 M3–M4 接入,详见 [3.3.0 架构设计](docs/DESIGN-v3.3.0-cost-optimization.md)。
95
+ - **3.3.1 发布线**:M5 收口 —— 瓮城回流接口升级到 `@twinsearth/dsh-fang-contract` **1.1.0**(`fang.relay.publish` / `fang.relay.status`),房主侧 `src/cost/fang-relay.ts` 做真实探测与投递(只有对端回执才算送达,不可用给具名原因);里程碑 **M1–M5 全部 done**。详见 [M5 回流实测](docs/EXPERIMENT-M5-fang-relay.md)。
96
+ - **3.3.0 发布线**:成本优化系统层(ledger / budget / policy / audit)+ Worker 四个成本切点 + A1 紧凑化(默认 shadow)+ A2–A3 / B1–B4;真实 A/B 实测 tokens/task 6263 → 3906.75(−37.6%),成功率 0pp 差,详见 [3.3.0 架构设计](docs/DESIGN-v3.3.0-cost-optimization.md)。
96
97
  - 3.2.10 发布线:角色矩阵放宽——Router 进入母体(essence/mother),Worker 与 Worker Agent Loop 进入原身(fork/essence);母体仍不接纳执行体,fork 仍不注册 Router。
97
98
  - 3.2.9 发布线:房主面板改注册到 `shell.overlay` 常驻浮层——点击面板之外不收起、侧栏「房主」按钮二次点击收起、正文独立滚动;新增无浏览器 hook 运行时行为测试(开关、无 outside-click 监听、滚动容器、SSE 失败不卸载面板)。
98
99
  - 3.2.8 发布线:与瓮城通过 fang-master contract 1.0 互联(local/http/file/legacy 四级保底);GitHub Release + NPM public。
package/client.js CHANGED
@@ -275,7 +275,7 @@ window.__ModuleLoader__.load({
275
275
  return h('div', { style: S.card, 'data-dsh-master': 'cost-block' },
276
276
  h('div', { style: S.row },
277
277
  h('span', { style: S.name }, '成本(只读)'),
278
- tag(block ? '3.3.0' : '未启用', block ? undefined : '#B45309')),
278
+ tag(block ? '3.3.1' : '未启用', block ? undefined : '#B45309')),
279
279
  failed ? h('div', { style: S.error }, '成本接口不可达') : null,
280
280
  !block && !failed ? h('div', { style: S.desc }, '成本系统未装配(cost-optimizer 未启用或角色不匹配)') : null,
281
281
  columns ? h('div', { style: S.desc },
@@ -77,6 +77,13 @@ export function createContractClient(options = {}) {
77
77
  async resolveDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_RESOLVE, params, invokeOptions); },
78
78
  async listSessions(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.SESSION_LIST, params, invokeOptions); },
79
79
  async orchestratorStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.ORCHESTRATOR_STATUS, params, invokeOptions); },
80
+ /** contract 1.1:批量投递回流事件(按 event.id 幂等;返回回执与游标)。 */
81
+ async publishRelay(events, invokeOptions = {}) {
82
+ const params = Array.isArray(events) ? { events } : (events && typeof events === 'object' ? events : { events: [] });
83
+ return await client.invoke(METHODS.RELAY_PUBLISH, params, invokeOptions);
84
+ },
85
+ /** contract 1.1:读取对端回流状态(对端自报 peer/cursor/累计计数)。 */
86
+ async relayStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.RELAY_STATUS, params, invokeOptions); },
80
87
  };
81
88
  return client;
82
89
  }
@@ -9,7 +9,7 @@
9
9
  * * 删除/改义必须升 MAJOR,并保留至少一个 MINOR 周期的双写(method@1 与 method@2)。
10
10
  */
11
11
  export const CONTRACT_PACKAGE = '@twinsearth/dsh-fang-contract';
12
- export const CONTRACT_VERSION = '1.0.0';
12
+ export const CONTRACT_VERSION = '1.1.0';
13
13
  export const PROTOCOL = 'fang-master';
14
14
  export const PROTOCOL_VERSION = 1;
15
15
  export const MIN_COMPATIBLE_MAJOR = 1;
@@ -24,6 +24,8 @@ export const METHODS = Object.freeze({
24
24
  DECISION_RESOLVE: 'fang.decision.resolve',
25
25
  SESSION_LIST: 'fang.session.list',
26
26
  ORCHESTRATOR_STATUS: 'fang.orchestrator.status',
27
+ RELAY_PUBLISH: 'fang.relay.publish',
28
+ RELAY_STATUS: 'fang.relay.status',
27
29
  });
28
30
  /** 方法 → 契约版本(用于协商后的方法裁剪)。 */
29
31
  export const METHOD_VERSION = Object.freeze({
@@ -36,9 +38,11 @@ export const METHOD_VERSION = Object.freeze({
36
38
  [METHODS.DECISION_RESOLVE]: '1.0',
37
39
  [METHODS.SESSION_LIST]: '1.0',
38
40
  [METHODS.ORCHESTRATOR_STATUS]: '1.0',
41
+ [METHODS.RELAY_PUBLISH]: '1.1',
42
+ [METHODS.RELAY_STATUS]: '1.1',
39
43
  });
40
44
  export const CAPABILITIES = Object.freeze([
41
- 'sync', 'async', 'idempotent', 'stream-events', 'file-outbox',
45
+ 'sync', 'async', 'idempotent', 'stream-events', 'file-outbox', 'event-relay',
42
46
  ]);
43
47
  export const ROLES = Object.freeze(['mother', 'essence', 'fork']);
44
48
  export const ERROR_CODES = Object.freeze({
@@ -68,3 +72,13 @@ export const LEGACY_SERVICE_KEYS = Object.freeze({
68
72
  sessionCatalog: 'fangSessionCatalog',
69
73
  orchestrator: 'fang',
70
74
  });
75
+ /**
76
+ * 回流接口的硬上限(1.1)。超限是**协议错误**而不是静默截断:
77
+ * 调用方必须自己分批,服务端不得悄悄丢事件(回流要可对账)。
78
+ */
79
+ export const RELAY_LIMITS = Object.freeze({
80
+ maxBatch: 128,
81
+ maxEventBytes: 32_000,
82
+ maxPayloadBytes: 512_000,
83
+ maxRecords: 5_000,
84
+ });
@@ -13,5 +13,6 @@ export * from './constants.js';
13
13
  export * from './envelope.js';
14
14
  export * from './negotiate.js';
15
15
  export * from './server.js';
16
+ export * from './relay.js';
16
17
  export * from './transports.js';
17
18
  export * from './client.js';
@@ -0,0 +1,239 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ /**
4
+ * 回流(relay)接口 —— contract 1.1 新增能力。
5
+ *
6
+ * 背景:房主(dsh-Master)的成本总线产生 `cost:event` / `cost:degraded` /
7
+ * `cost:substitution` 三类事件,需要**单向**投递给瓮城(dsh-Fang)做产物回流与
8
+ * 信息栏展示。1.0 的方法表只有请求-响应式的 fork/decision/session,缺一个批量、
9
+ * 可去重、有回执的事件入口,因此 1.1 追加两个方法:
10
+ *
11
+ * * `fang.relay.publish` —— 批量投递事件,按 `event.id` 幂等去重,返回回执与游标;
12
+ * * `fang.relay.status` —— 对端自报回流状态(自身标识 peerLabel、游标、累计计数)。
13
+ *
14
+ * 兼容规则(README §2):新增方法只升 MINOR;1.0 的对端在 `hello` 协商时不会
15
+ * 宣告这两个方法,方法集被自动裁剪,调用方据此判定“回流不可用”,**不得**把
16
+ * 裁剪当成失败重试。
17
+ *
18
+ * 本文件只做协议与参考实现:不读写磁盘、不联网、不持有任何凭据。
19
+ */
20
+ import { CONTRACT_VERSION, METHODS, RELAY_LIMITS } from './constants.js';
21
+ import { createContractServer } from './server.js';
22
+ function nowMs(now) {
23
+ const value = typeof now === 'function' ? now() : Date.now();
24
+ const n = Number(value);
25
+ return Number.isFinite(n) ? n : Date.now();
26
+ }
27
+ function asLimit(value, fallback) {
28
+ const n = Number(value);
29
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
30
+ }
31
+ function byteLength(value) {
32
+ try {
33
+ return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
34
+ }
35
+ catch {
36
+ return Number.POSITIVE_INFINITY;
37
+ }
38
+ }
39
+ /**
40
+ * 校验一批回流事件。逐条判定:合法的接受,非法的计 `rejected` 并给原因,
41
+ * 不因单条坏数据丢掉整批(但整批不可用会抛 `relay-events-invalid`)。
42
+ *
43
+ * @param {unknown} events
44
+ * @param {{maxBatch?:number, maxEventBytes?:number, maxPayloadBytes?:number}} [limits]
45
+ */
46
+ export function validateRelayEvents(events, limits = {}) {
47
+ const maxBatch = asLimit(limits.maxBatch, RELAY_LIMITS.maxBatch);
48
+ const maxEventBytes = asLimit(limits.maxEventBytes, RELAY_LIMITS.maxEventBytes);
49
+ const maxPayloadBytes = asLimit(limits.maxPayloadBytes, RELAY_LIMITS.maxPayloadBytes);
50
+ const errors = [];
51
+ if (!Array.isArray(events)) {
52
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-must-be-array'], accepted: [], rejected: [] };
53
+ }
54
+ if (events.length === 0) {
55
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-empty'], accepted: [], rejected: [] };
56
+ }
57
+ if (events.length > maxBatch) {
58
+ return { ok: false, code: 'relay-events-invalid', errors: [`events-too-many(>${maxBatch})`], accepted: [], rejected: [] };
59
+ }
60
+ if (byteLength(events) > maxPayloadBytes) {
61
+ return { ok: false, code: 'relay-events-invalid', errors: [`payload-too-large(>${maxPayloadBytes})`], accepted: [], rejected: [] };
62
+ }
63
+ const accepted = [];
64
+ const rejected = [];
65
+ const seen = new Set();
66
+ for (const raw of events) {
67
+ const item = raw && typeof raw === 'object' ? raw : null;
68
+ if (!item) {
69
+ rejected.push({ id: null, reason: 'event-not-object' });
70
+ continue;
71
+ }
72
+ const id = typeof item.id === 'string' ? item.id.trim() : '';
73
+ if (!id) {
74
+ rejected.push({ id: null, reason: 'id-required' });
75
+ continue;
76
+ }
77
+ if (typeof item.type !== 'string' || item.type.trim() === '') {
78
+ rejected.push({ id, reason: 'type-required' });
79
+ continue;
80
+ }
81
+ if (byteLength(item) > maxEventBytes) {
82
+ rejected.push({ id, reason: `event-too-large(>${maxEventBytes})` });
83
+ continue;
84
+ }
85
+ // 同一批内重复 id 也算重复投递:只接受第一次,其余计 duplicates。
86
+ if (seen.has(id)) {
87
+ rejected.push({ id, reason: 'duplicate-in-batch' });
88
+ continue;
89
+ }
90
+ seen.add(id);
91
+ accepted.push({
92
+ id,
93
+ type: String(item.type),
94
+ at: Number.isFinite(Number(item.at)) ? Number(item.at) : 0,
95
+ payload: item.payload && typeof item.payload === 'object' ? item.payload : {},
96
+ meta: item.meta && typeof item.meta === 'object' ? item.meta : {},
97
+ });
98
+ }
99
+ return { ok: accepted.length > 0 || rejected.length === 0, errors, accepted, rejected };
100
+ }
101
+ /**
102
+ * 回流接收端(瓮城侧)的参考存储:按 event.id 幂等去重,保留有界回执环。
103
+ *
104
+ * @param {{
105
+ * peerLabel?: string, role?: string, instanceId?: string,
106
+ * maxRecords?: number, now?: () => number, limits?: object,
107
+ * }} [options]
108
+ */
109
+ export function createRelayStore(options = {}) {
110
+ const peerLabel = String(options.peerLabel ?? 'contract-server');
111
+ const role = String(options.role ?? 'mother');
112
+ const instanceId = String(options.instanceId ?? '');
113
+ const maxRecords = asLimit(options.maxRecords, RELAY_LIMITS.maxRecords);
114
+ const limits = { ...RELAY_LIMITS, ...(options.limits && typeof options.limits === 'object' ? options.limits : {}) };
115
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
116
+ const seen = new Map();
117
+ const receipts = [];
118
+ const counters = { accepted: 0, duplicates: 0, rejected: 0, batches: 0 };
119
+ let cursor = 0;
120
+ let lastAt = 0;
121
+ const lastErrors = [];
122
+ function remember(receipt) {
123
+ receipts.push(receipt);
124
+ if (receipts.length > maxRecords)
125
+ receipts.shift();
126
+ }
127
+ /** @param {{events?: unknown, batchId?: string, taskId?: string}} [params] */
128
+ function publish(params = {}) {
129
+ const validated = validateRelayEvents(params?.events, limits);
130
+ if (validated.ok === false) {
131
+ const error = new Error(validated.errors.join(', ') || 'relay-events-invalid');
132
+ error.code = 'relay-events-invalid';
133
+ error.retryable = false;
134
+ throw error;
135
+ }
136
+ counters.batches += 1;
137
+ const acceptedIds = [];
138
+ let duplicates = 0;
139
+ for (const event of validated.accepted) {
140
+ if (seen.has(event.id)) {
141
+ duplicates += 1;
142
+ continue;
143
+ }
144
+ seen.set(event.id, { at: nowMs(now), cursor: cursor + 1 });
145
+ cursor += 1;
146
+ counters.accepted += 1;
147
+ acceptedIds.push(event.id);
148
+ remember({ id: event.id, type: event.type, at: event.at, cursor });
149
+ lastAt = nowMs(now);
150
+ }
151
+ counters.duplicates += duplicates;
152
+ for (const item of validated.rejected) {
153
+ // 批内重复计入 duplicates;其余非法条目计入 rejected 并留原因(有界)。
154
+ if (item.reason === 'duplicate-in-batch') {
155
+ duplicates += 1;
156
+ counters.duplicates += 1;
157
+ continue;
158
+ }
159
+ counters.rejected += 1;
160
+ if (lastErrors.length < 50)
161
+ lastErrors.push({ id: item.id, reason: item.reason });
162
+ }
163
+ const batchId = String(params?.batchId ?? `batch-${counters.batches}`);
164
+ const receipt = Object.freeze({
165
+ ok: true,
166
+ batchId,
167
+ taskId: params?.taskId === undefined ? null : String(params.taskId),
168
+ accepted: acceptedIds.length,
169
+ duplicates,
170
+ rejected: validated.rejected.length - duplicates,
171
+ cursor,
172
+ acceptedIds,
173
+ peer: peerLabel,
174
+ at: nowMs(now),
175
+ });
176
+ if (seen.size > maxRecords * 4) {
177
+ // 幂等窗口有界:按插入顺序淘汰最老的一半,避免长跑进程无界增长。
178
+ const overflow = seen.size - maxRecords * 2;
179
+ let removed = 0;
180
+ for (const key of seen.keys()) {
181
+ if (removed >= overflow)
182
+ break;
183
+ seen.delete(key);
184
+ removed += 1;
185
+ }
186
+ }
187
+ return receipt;
188
+ }
189
+ function status() {
190
+ return Object.freeze({
191
+ ok: true,
192
+ available: true,
193
+ peer: peerLabel,
194
+ role,
195
+ instanceId,
196
+ contractVersion: CONTRACT_VERSION,
197
+ cursor,
198
+ accepted: counters.accepted,
199
+ duplicates: counters.duplicates,
200
+ rejected: counters.rejected,
201
+ batches: counters.batches,
202
+ lastAt,
203
+ limits: { ...limits },
204
+ lastErrors: [...lastErrors],
205
+ });
206
+ }
207
+ function describe() {
208
+ return Object.freeze({ peerLabel, records: receipts.length, seen: seen.size, ...counters, cursor });
209
+ }
210
+ return Object.freeze({ publish, status, describe, receipts: () => [...receipts] });
211
+ }
212
+ /**
213
+ * 把回流 store 包成契约 handler 表(挂到 `createContractServer({handlers})`)。
214
+ * @param {{publish:Function, status:Function}} store
215
+ */
216
+ export function relayHandlers(store) {
217
+ if (!store || typeof store.publish !== 'function' || typeof store.status !== 'function') {
218
+ throw new Error('relayHandlers requires a relay store with publish/status');
219
+ }
220
+ return {
221
+ [METHODS.RELAY_PUBLISH]: (params) => store.publish(params ?? {}),
222
+ [METHODS.RELAY_STATUS]: () => store.status(),
223
+ };
224
+ }
225
+ /**
226
+ * 便捷构造:带回流能力的契约服务端(瓮城侧挂载用)。
227
+ * `peerLabel` 是**自报**标识,房主侧据此记录“对端是谁”,不参与鉴权。
228
+ */
229
+ export function createRelayContractServer(options = {}) {
230
+ const { peerLabel = 'contract-server', role = 'mother', instanceId = '', now, limits, maxRecords, extraHandlers = {}, ...rest } = options;
231
+ const store = createRelayStore({ peerLabel, role, instanceId, now, limits, maxRecords });
232
+ const server = createContractServer({
233
+ role,
234
+ instanceId,
235
+ handlers: { ...relayHandlers(store), ...(extraHandlers && typeof extraHandlers === 'object' ? extraHandlers : {}) },
236
+ ...rest,
237
+ });
238
+ return { server, store };
239
+ }
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.1 · 瓮城回流出口(设计 §5.6,里程碑 M5)。
6
+ *
7
+ * 3.3.0 里这条通道是硬编码的 `{ target:'fang', available:false,
8
+ * reason:'fang-channel-not-ready' }`:接口位在,但没有实现。3.3.1 把它换成
9
+ * **真实探测 + 真实投递**:
10
+ *
11
+ * 1. `probe()`:用契约 SDK 的 `hello()` 协商,看对端在公共 minor 下是否宣告
12
+ * `fang.relay.publish` / `fang.relay.status`(contract 1.1 才有的方法);
13
+ * 宣告了再读一次 `fang.relay.status` 确认对端**真的在服务**,不是只挂方法名。
14
+ * 2. `enqueue()` / `flush()`:把 `cost:event` / `cost:degraded` /
15
+ * `cost:substitution` 批量投递,按事件 id 幂等,回执里的 `accepted` /
16
+ * `duplicates` / `cursor` 才是“送达”的唯一证据。
17
+ * 3. `status()`:同步返回**最近一次已知状态**(`probe()` 异步更新)。
18
+ *
19
+ * 两条硬约束(与 I1–I8 一致):
20
+ * * 不可用必须显式 `available:false` + 具体原因,**不得**把队列长度当送达;
21
+ * * 投递失败不重试成“假成功”:批次退回队列、`failed` 计数、状态转不可用,
22
+ * 并保留 `lastError`。
23
+ *
24
+ * 对端身份(`peer`)由对端在 `fang.relay.status` 里**自报**(如
25
+ * `dsh-fang-bundle` / `contract-server`),只用于记录“对端是谁”,不参与鉴权,
26
+ * 也不得被当成“已对接真实瓮城”的证据 —— 那需要 `peer === 'dsh-fang-bundle'`。
27
+ */
28
+ import path from 'node:path';
29
+ import { METHODS, createContractClient } from '../contract/index.js';
30
+ /** 允许作为 M5 证据的对端标识(自报值白名单)。 */
31
+ export const RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server']);
32
+ /** 默认值:批量/队列/探测都有界,避免长跑进程无界增长。 */
33
+ export const RELAY_DEFAULTS = Object.freeze({
34
+ maxBatch: 32,
35
+ maxQueue: 500,
36
+ probeTtlMs: 60_000,
37
+ timeoutMs: 15_000,
38
+ });
39
+ function asString(value) { return String(value ?? '').trim(); }
40
+ function clampInt(value, fallback, min, max) {
41
+ const n = Number(value);
42
+ if (!Number.isFinite(n))
43
+ return fallback;
44
+ return Math.min(max, Math.max(min, Math.floor(n)));
45
+ }
46
+ function describeError(error) {
47
+ const code = asString(error?.code) || 'relay-error';
48
+ const message = asString(error?.message ?? error);
49
+ return message ? `${code}:${message.slice(0, 160)}` : code;
50
+ }
51
+ /**
52
+ * 创建回流出口。
53
+ *
54
+ * @param {{
55
+ * client?: object | null, // createContractClient() 的返回值;null ⇒ 明确不可用
56
+ * target?: string,
57
+ * now?: () => number,
58
+ * maxBatch?: number, maxQueue?: number, probeTtlMs?: number, timeoutMs?: number,
59
+ * logger?: object,
60
+ * }} [options]
61
+ */
62
+ export function createFangRelay(options = {}) {
63
+ const client = options.client && typeof options.client === 'object' ? options.client : null;
64
+ const target = asString(options.target) || 'fang';
65
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
66
+ const maxBatch = clampInt(options.maxBatch, RELAY_DEFAULTS.maxBatch, 1, 128);
67
+ const maxQueue = clampInt(options.maxQueue, RELAY_DEFAULTS.maxQueue, 1, 5_000);
68
+ const probeTtlMs = clampInt(options.probeTtlMs, RELAY_DEFAULTS.probeTtlMs, 1_000, 600_000);
69
+ const timeoutMs = clampInt(options.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000);
70
+ const logger = options.logger ?? null;
71
+ const queue = [];
72
+ const counters = { enqueued: 0, delivered: 0, duplicates: 0, failed: 0, dropped: 0, batches: 0, roundTrips: 0 };
73
+ let sequence = 0;
74
+ let batchSequence = 0;
75
+ let cursor = null;
76
+ let lastError = null;
77
+ let lastDeliveredAt = 0;
78
+ let probing = null;
79
+ // 没有客户端时开局即知不可用(不需要“探测中”这种灰色状态去掩盖)。
80
+ let state = Object.freeze({
81
+ available: false,
82
+ reason: client ? 'probe-pending' : 'relay-client-absent',
83
+ peer: null,
84
+ peerContractVersion: null,
85
+ transport: null,
86
+ probedAt: 0,
87
+ });
88
+ function setState(next) {
89
+ state = Object.freeze({ ...state, probedAt: now(), ...next });
90
+ }
91
+ function status() {
92
+ return Object.freeze({
93
+ target,
94
+ available: state.available === true,
95
+ reason: state.reason ?? null,
96
+ peer: state.peer ?? null,
97
+ peerContractVersion: state.peerContractVersion ?? null,
98
+ transport: state.transport ?? null,
99
+ delivered: counters.delivered,
100
+ failed: counters.failed,
101
+ queued: queue.length,
102
+ dropped: counters.dropped,
103
+ duplicates: counters.duplicates,
104
+ batches: counters.batches,
105
+ cursor,
106
+ lastError,
107
+ lastDeliveredAt,
108
+ probedAt: state.probedAt,
109
+ limits: { maxBatch, maxQueue, probeTtlMs, timeoutMs },
110
+ });
111
+ }
112
+ /** 探测对端:hello 协商 → 方法裁剪判定 → 读一次 relay 状态确认真的可用。 */
113
+ async function probe({ force = false } = {}) {
114
+ if (!client) {
115
+ setState({ available: false, reason: 'relay-client-absent', peer: null });
116
+ return status();
117
+ }
118
+ if (!force && state.probedAt > 0 && now() - state.probedAt < probeTtlMs)
119
+ return status();
120
+ if (probing)
121
+ return probing;
122
+ probing = (async () => {
123
+ try {
124
+ const hello = await client.hello(true);
125
+ if (!hello || hello.ok !== true) {
126
+ setState({ available: false, reason: `hello-failed:${asString(hello?.code) || 'unknown'}`, peer: null });
127
+ return status();
128
+ }
129
+ const methods = Array.isArray(hello.negotiation?.methods) ? hello.negotiation.methods : [];
130
+ const peerContractVersion = asString(hello.hello?.contractVersion) || null;
131
+ const offersRelay = methods.includes(METHODS.RELAY_PUBLISH) && methods.includes(METHODS.RELAY_STATUS);
132
+ if (!offersRelay) {
133
+ // 1.0 对端不会宣告 1.1 方法;这是“协议层没有回流”,不是传输故障。
134
+ setState({
135
+ available: false,
136
+ reason: `peer-contract-without-relay:${peerContractVersion ?? 'unknown'}`,
137
+ peer: null,
138
+ peerContractVersion,
139
+ transport: asString(hello.transport) || null,
140
+ });
141
+ return status();
142
+ }
143
+ const remote = await client.relayStatus({}, { timeoutMs });
144
+ if (!remote || remote.ok !== true) {
145
+ setState({
146
+ available: false,
147
+ reason: `relay-status-failed:${asString(remote?.code) || 'unknown'}`,
148
+ peer: null,
149
+ peerContractVersion,
150
+ transport: asString(hello.transport) || null,
151
+ });
152
+ return status();
153
+ }
154
+ const result = remote.response?.result ?? {};
155
+ cursor = Number.isFinite(Number(result.cursor)) ? Number(result.cursor) : cursor;
156
+ setState({
157
+ available: true,
158
+ reason: null,
159
+ peer: asString(result.peer) || null,
160
+ peerContractVersion: asString(result.contractVersion) || peerContractVersion,
161
+ transport: asString(remote.transport ?? hello.transport) || null,
162
+ });
163
+ return status();
164
+ }
165
+ catch (error) {
166
+ setState({ available: false, reason: `probe-threw:${describeError(error)}`, peer: null });
167
+ try {
168
+ logger?.warn?.(`[cost-relay] probe failed: ${describeError(error)}`);
169
+ }
170
+ catch { /* ignore */ }
171
+ return status();
172
+ }
173
+ })();
174
+ try {
175
+ return await probing;
176
+ }
177
+ finally {
178
+ probing = null;
179
+ }
180
+ }
181
+ /** 入队一条总线事件;不可用时不假装收下(记 dropped 并返回原因)。 */
182
+ function enqueue(event = {}) {
183
+ if (state.available !== true) {
184
+ counters.dropped += 1;
185
+ return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown' };
186
+ }
187
+ const type = asString(event.type) || 'cost:event';
188
+ sequence += 1;
189
+ const item = Object.freeze({
190
+ // id 稳定且唯一:重投同一批时对端按 id 去重,不会重复记账。
191
+ id: `cost-${type}-${sequence}-${Math.max(0, Math.floor(Number(event.at ?? now()) || 0))}`,
192
+ type,
193
+ at: Math.max(0, Math.floor(Number(event.at ?? now()) || 0)),
194
+ payload: event.payload && typeof event.payload === 'object' ? { ...event.payload } : {},
195
+ });
196
+ if (queue.length >= maxQueue) {
197
+ queue.shift();
198
+ counters.dropped += 1;
199
+ }
200
+ queue.push(item);
201
+ counters.enqueued += 1;
202
+ return { ok: true, id: item.id, queued: queue.length };
203
+ }
204
+ /** 投递一个批次;失败时批次退回队列并把状态转不可用(不吞错)。 */
205
+ async function flush() {
206
+ if (!client)
207
+ return { ok: false, code: 'relay-client-absent', ...status() };
208
+ if (state.available !== true)
209
+ return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown', ...status() };
210
+ if (queue.length === 0)
211
+ return { ok: true, delivered: 0, sent: 0, queued: 0 };
212
+ const batch = queue.slice(0, maxBatch);
213
+ batchSequence += 1;
214
+ const batchId = `relay-${batchSequence}`;
215
+ let outcome = null;
216
+ try {
217
+ outcome = await client.publishRelay({ batchId, events: batch }, { timeoutMs });
218
+ }
219
+ catch (error) {
220
+ outcome = { ok: false, code: 'relay-threw', error: describeError(error) };
221
+ }
222
+ counters.roundTrips += 1;
223
+ if (outcome && outcome.ok === true) {
224
+ const result = outcome.response?.result ?? {};
225
+ const accepted = Math.max(0, Number(result.accepted ?? 0) || 0);
226
+ const duplicates = Math.max(0, Number(result.duplicates ?? 0) || 0);
227
+ // 只有对端回执才算送达:从队列头移除本次实际发送的批次。
228
+ queue.splice(0, batch.length);
229
+ counters.batches += 1;
230
+ counters.delivered += accepted + duplicates;
231
+ counters.duplicates += duplicates;
232
+ if (Number.isFinite(Number(result.cursor)))
233
+ cursor = Number(result.cursor);
234
+ lastDeliveredAt = now();
235
+ lastError = null;
236
+ return { ok: true, delivered: accepted, duplicates, sent: batch.length, queued: queue.length, cursor, batchId };
237
+ }
238
+ const code = asString(outcome?.code) || 'publish-failed';
239
+ const detail = asString(outcome?.failures?.[0]?.code) || asString(outcome?.error);
240
+ lastError = detail && !detail.startsWith(code) ? `${code}:${detail}`.slice(0, 200) : (detail || code);
241
+ counters.failed += batch.length;
242
+ setState({ available: false, reason: `publish-failed:${lastError}` });
243
+ try {
244
+ logger?.warn?.(`[cost-relay] publish failed: ${lastError}`);
245
+ }
246
+ catch { /* ignore */ }
247
+ return { ok: false, code, error: lastError, sent: batch.length, queued: queue.length };
248
+ }
249
+ /** 挂到事件总线上:订阅者异常与投递异常都不影响主流程。 */
250
+ function attach(bus) {
251
+ if (!bus || typeof bus.subscribe !== 'function')
252
+ return () => { };
253
+ return bus.subscribe((event) => {
254
+ try {
255
+ enqueue(event);
256
+ }
257
+ catch { /* 回流不得影响主流程 */ }
258
+ });
259
+ }
260
+ function reset() {
261
+ queue.length = 0;
262
+ lastError = null;
263
+ setState({ available: false, reason: 'probe-pending', peer: null });
264
+ return status();
265
+ }
266
+ return Object.freeze({ target, probe, enqueue, flush, attach, status, reset, describe: status });
267
+ }
268
+ /**
269
+ * 从插件配置/环境装配契约客户端。
270
+ *
271
+ * 未配置任何传输时返回 null(**不**创建空客户端去假装探测过)。
272
+ *
273
+ * @param {object} ctx
274
+ * @param {{relay?: object, dispatch?: object}} [config]
275
+ * @param {{fetchImpl?: typeof fetch, env?: object}} [options]
276
+ */
277
+ export function createRelayClient(ctx, config = {}, options = {}) {
278
+ const raw = config.relay && typeof config.relay === 'object' ? config.relay : {};
279
+ const dispatch = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {};
280
+ const env = options.env ?? process.env;
281
+ const fetchImpl = options.fetchImpl ?? fetch;
282
+ const httpBaseUrl = asString(raw.httpBaseUrl) || asString(dispatch.motherUrl) || asString(env.DSH_FANG_CONTRACT_URL) || asString(env.DSH_MASTER_MOTHER_URL);
283
+ const httpToken = asString(raw.httpToken) || asString(dispatch.motherToken) || asString(env.DSH_FANG_CONTRACT_TOKEN);
284
+ const motherDataDir = asString(env.DSH_FANG_MOTHER);
285
+ const fileDir = asString(raw.fileDir) || asString(dispatch.contractFileDir) || (motherDataDir ? path.join(motherDataDir, 'contract-outbox') : '');
286
+ const localService = (() => {
287
+ try {
288
+ return ctx && typeof ctx.get === 'function' ? ctx.get('fangContract') : null;
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ })();
294
+ const enabled = raw.enabled === true || Boolean(httpBaseUrl) || Boolean(fileDir) || Boolean(localService);
295
+ if (!enabled)
296
+ return null;
297
+ if (raw.enabled === false)
298
+ return null;
299
+ return createContractClient({
300
+ ctx,
301
+ config: {
302
+ httpBaseUrl,
303
+ httpToken,
304
+ httpHeaders: raw.httpHeaders && typeof raw.httpHeaders === 'object' ? raw.httpHeaders : undefined,
305
+ fileDir,
306
+ timeoutMs: clampInt(raw.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000),
307
+ fileTimeoutMs: clampInt(raw.fileTimeoutMs, 30_000, 1_000, 300_000),
308
+ order: Array.isArray(raw.order) ? raw.order : undefined,
309
+ },
310
+ fetchImpl,
311
+ });
312
+ }
313
+ /**
314
+ * 一次性装配:配置 → 客户端 → 出口。返回的 `client` 为 null 表示没有任何
315
+ * 可用传输(此时出口状态是 `relay-client-absent`,不是“探测中”)。
316
+ */
317
+ export function createFangRelayFromContext(ctx, config = {}, options = {}) {
318
+ const client = createRelayClient(ctx, config, options);
319
+ const relay = createFangRelay({
320
+ client,
321
+ now: options.now,
322
+ maxBatch: config.relay?.maxBatch,
323
+ maxQueue: config.relay?.maxQueue,
324
+ probeTtlMs: config.relay?.probeTtlMs,
325
+ timeoutMs: config.relay?.timeoutMs,
326
+ logger: options.logger,
327
+ });
328
+ return { client, relay };
329
+ }