@twinsearth/w2m-dsh-plugin 3.1.0 → 3.2.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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +79 -0
- package/README.md +16 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +5 -0
- package/docs/INTEROP-CONTRACT.md +126 -0
- package/docs/acceptance/3.2.0/RELEASE-3200.json +47 -0
- package/docs/acceptance/3.2.1/RELEASE-3210.json +50 -0
- package/docs/acceptance/3.2.2/RELEASE-3220.json +53 -0
- package/lib/client.js +1216 -3
- package/lib/contract.js +22 -0
- package/package.json +7 -4
- package/plugin.json +1 -1
- package/src/interop/contract/LICENSE +661 -0
- package/src/interop/contract/PROVENANCE.md +26 -0
- package/src/interop/contract/client.js +93 -0
- package/src/interop/contract/constants.js +94 -0
- package/src/interop/contract/envelope.js +83 -0
- package/src/interop/contract/index.js +20 -0
- package/src/interop/contract/negotiate.js +106 -0
- package/src/interop/contract/relay.js +229 -0
- package/src/interop/contract/server.js +77 -0
- package/src/interop/contract/transports.js +319 -0
- package/src/interop/index.mjs +270 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 契约 SDK 副本来源(PROVENANCE)
|
|
2
|
+
|
|
3
|
+
本目录是 `@twinsearth/dsh-fang-contract@1.1.1` 的**逐字节副本**,来源是 npm 上已发布的包,
|
|
4
|
+
不是对 dsh-fang / dsh-master 仓库的改动。按 w2m 会话守则(`E:/GPT/w2m/AGENTS.md`),
|
|
5
|
+
三个项目相互独立:w2m 只经由发布出来的版本化契约 SDK 互联,不 import 对方内部代码。
|
|
6
|
+
|
|
7
|
+
- 包名:`@twinsearth/dsh-fang-contract`
|
|
8
|
+
- 版本:`1.1.1`(`CONTRACT_VERSION` 常量与 package.json 一致)
|
|
9
|
+
- 来源:npm registry,`npm pack @twinsearth/dsh-fang-contract@1.1.1`
|
|
10
|
+
- 许可证:AGPL-3.0-or-later(见本目录 `LICENSE`)
|
|
11
|
+
- 语义不改:w2m 自己的方法放进 `w2m.*` 命名空间,不改 `fang.*` 任何语义。
|
|
12
|
+
|
|
13
|
+
| 文件 | 字节 | sha256 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| LICENSE | 34523 | 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 |
|
|
16
|
+
| client.js | 4021 | b1c48a56c64e7058f40a6f1e3239f9e81619381c121fc3d2e1f5418b6e8c6b26 |
|
|
17
|
+
| constants.js | 3404 | 6ce1d813a6a61cfc991c4888f813e7baeff828802ba03f375c6b49e6b0329979 |
|
|
18
|
+
| envelope.js | 3164 | 68e8725a4b7d5c01e49b1364a201121278d3d45dac6e99dc0ee53f98232d380a |
|
|
19
|
+
| index.js | 780 | 67b5d687d4f8c47fd4d573fe5540362014ed0617e7df3533936e613a439b655c |
|
|
20
|
+
| negotiate.js | 3993 | 8a699b287b740a7d478c40981c0ae7915a6522ff856f3bc0b19f0cc077587c43 |
|
|
21
|
+
| relay.js | 9499 | 9f7150aaa026ea4ca629b749a2bc691579b72836af053e05228070a009f5b5b2 |
|
|
22
|
+
| server.js | 3255 | 5e7c884639fc365833f6fdca91f276c34db2b1d1a6938790c7e9b71c127e6c17 |
|
|
23
|
+
| transports.js | 16872 | 6712aabbe702ae559ff50027a3469c0ce031092e6b0a61fec51cfe80b69d0a6c |
|
|
24
|
+
|
|
25
|
+
升级流程:`npm pack` 取新版本 → 覆盖本目录 → 重跑生成脚本更新上表 →
|
|
26
|
+
`node scripts/check-contract-vendor.mjs`(本仓库的 pre-flight 会跑它)。
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_TRANSPORT_ORDER, METHODS } from './constants.js'
|
|
5
|
+
import {
|
|
6
|
+
createFileTransport,
|
|
7
|
+
createHttpTransport,
|
|
8
|
+
createLegacyTransport,
|
|
9
|
+
createLocalTransport,
|
|
10
|
+
createTransportRouter,
|
|
11
|
+
} from './transports.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 客户端门面:一次配置,按 local → http → file → legacy 自动降级。
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* ctx?: object,
|
|
18
|
+
* config?: {httpBaseUrl?:string, httpToken?:string, fileDir?:string, order?:string[], timeoutMs?:number},
|
|
19
|
+
* fetchImpl?: typeof fetch,
|
|
20
|
+
* logger?: object,
|
|
21
|
+
* instanceId?: string,
|
|
22
|
+
* }} options
|
|
23
|
+
*/
|
|
24
|
+
export function createContractClient(options = {}) {
|
|
25
|
+
const { ctx = null, config = {}, fetchImpl = fetch, logger = null, instanceId = 'contract-client' } = options
|
|
26
|
+
const transports = []
|
|
27
|
+
if (ctx) transports.push(createLocalTransport({ ctx }))
|
|
28
|
+
if (config.httpBaseUrl) transports.push(createHttpTransport({
|
|
29
|
+
baseUrl: config.httpBaseUrl,
|
|
30
|
+
token: config.httpToken,
|
|
31
|
+
headers: config.httpHeaders,
|
|
32
|
+
fetchImpl,
|
|
33
|
+
timeoutMs: config.timeoutMs,
|
|
34
|
+
pathPrefix: config.pathPrefix,
|
|
35
|
+
}))
|
|
36
|
+
if (config.fileDir) transports.push(createFileTransport({
|
|
37
|
+
dir: config.fileDir,
|
|
38
|
+
pollMs: config.filePollMs,
|
|
39
|
+
timeoutMs: config.fileTimeoutMs,
|
|
40
|
+
}))
|
|
41
|
+
if (ctx) transports.push(createLegacyTransport({ ctx }))
|
|
42
|
+
|
|
43
|
+
const order = Array.isArray(config.order) && config.order.length > 0 ? config.order : DEFAULT_TRANSPORT_ORDER
|
|
44
|
+
const router = createTransportRouter({
|
|
45
|
+
transports,
|
|
46
|
+
order,
|
|
47
|
+
localHello: null,
|
|
48
|
+
logger,
|
|
49
|
+
})
|
|
50
|
+
let lastHello = null
|
|
51
|
+
let helloPromise = null
|
|
52
|
+
|
|
53
|
+
const client = {
|
|
54
|
+
instanceId,
|
|
55
|
+
transports: () => router.transports(),
|
|
56
|
+
describe: () => ({
|
|
57
|
+
instanceId,
|
|
58
|
+
routes: router.describe(),
|
|
59
|
+
hello: lastHello,
|
|
60
|
+
}),
|
|
61
|
+
attempts: () => router.attempts(),
|
|
62
|
+
async hello(force = false) {
|
|
63
|
+
if (!force && lastHello) return lastHello
|
|
64
|
+
if (!force && helloPromise) return helloPromise
|
|
65
|
+
helloPromise = (async () => {
|
|
66
|
+
const result = await router.hello()
|
|
67
|
+
lastHello = result
|
|
68
|
+
return result
|
|
69
|
+
})().finally(() => { helloPromise = null })
|
|
70
|
+
return helloPromise
|
|
71
|
+
},
|
|
72
|
+
async invoke(method, params, invokeOptions = {}) {
|
|
73
|
+
const result = await router.invoke(method, params, { timeoutMs: config.timeoutMs, ...invokeOptions })
|
|
74
|
+
return result
|
|
75
|
+
},
|
|
76
|
+
async health() { return await client.invoke(METHODS.HEALTH, null) },
|
|
77
|
+
async spawnFork(params, invokeOptions = {}) { return await client.invoke(METHODS.FORK_SPAWN, params, invokeOptions) },
|
|
78
|
+
async stopFork(params, invokeOptions = {}) { return await client.invoke(METHODS.FORK_STOP, params, invokeOptions) },
|
|
79
|
+
async listForks(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.FORK_LIST, params, invokeOptions) },
|
|
80
|
+
async createDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_CREATE, params, invokeOptions) },
|
|
81
|
+
async resolveDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_RESOLVE, params, invokeOptions) },
|
|
82
|
+
async listSessions(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.SESSION_LIST, params, invokeOptions) },
|
|
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) },
|
|
91
|
+
}
|
|
92
|
+
return client
|
|
93
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 契约常量与兼容规则(contract SDK 1.x)。
|
|
6
|
+
*
|
|
7
|
+
* 稳定承诺:
|
|
8
|
+
* * MAJOR 相同的版本保证“旧字段可读、旧方法可调、未知字段忽略”;
|
|
9
|
+
* * 新增能力只通过 METHODS/CAPABILITIES 扩展,不改变既有方法语义;
|
|
10
|
+
* * 删除/改义必须升 MAJOR,并保留至少一个 MINOR 周期的双写(method@1 与 method@2)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const CONTRACT_PACKAGE = '@twinsearth/dsh-fang-contract'
|
|
14
|
+
export const CONTRACT_VERSION = '1.1.1'
|
|
15
|
+
export const PROTOCOL = 'fang-master'
|
|
16
|
+
export const PROTOCOL_VERSION = 1
|
|
17
|
+
export const MIN_COMPATIBLE_MAJOR = 1
|
|
18
|
+
|
|
19
|
+
/** 稳定方法名(新方法只能追加)。 */
|
|
20
|
+
export const METHODS = Object.freeze({
|
|
21
|
+
HELLO: 'contract.hello',
|
|
22
|
+
HEALTH: 'contract.health',
|
|
23
|
+
FORK_SPAWN: 'fang.fork.spawn',
|
|
24
|
+
FORK_STOP: 'fang.fork.stop',
|
|
25
|
+
FORK_LIST: 'fang.fork.list',
|
|
26
|
+
DECISION_CREATE: 'fang.decision.create',
|
|
27
|
+
DECISION_RESOLVE: 'fang.decision.resolve',
|
|
28
|
+
SESSION_LIST: 'fang.session.list',
|
|
29
|
+
ORCHESTRATOR_STATUS: 'fang.orchestrator.status',
|
|
30
|
+
RELAY_PUBLISH: 'fang.relay.publish',
|
|
31
|
+
RELAY_STATUS: 'fang.relay.status',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** 方法 → 契约版本(用于协商后的方法裁剪)。 */
|
|
35
|
+
export const METHOD_VERSION = Object.freeze({
|
|
36
|
+
[METHODS.HELLO]: '1.0',
|
|
37
|
+
[METHODS.HEALTH]: '1.0',
|
|
38
|
+
[METHODS.FORK_SPAWN]: '1.0',
|
|
39
|
+
[METHODS.FORK_STOP]: '1.0',
|
|
40
|
+
[METHODS.FORK_LIST]: '1.0',
|
|
41
|
+
[METHODS.DECISION_CREATE]: '1.0',
|
|
42
|
+
[METHODS.DECISION_RESOLVE]: '1.0',
|
|
43
|
+
[METHODS.SESSION_LIST]: '1.0',
|
|
44
|
+
[METHODS.ORCHESTRATOR_STATUS]: '1.0',
|
|
45
|
+
[METHODS.RELAY_PUBLISH]: '1.1',
|
|
46
|
+
[METHODS.RELAY_STATUS]: '1.1',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const CAPABILITIES = Object.freeze([
|
|
50
|
+
'sync', 'async', 'idempotent', 'stream-events', 'file-outbox', 'event-relay',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
export const ROLES = Object.freeze(['mother', 'essence', 'fork'])
|
|
54
|
+
|
|
55
|
+
export const ERROR_CODES = Object.freeze({
|
|
56
|
+
INVALID_ENVELOPE: 'invalid-envelope',
|
|
57
|
+
UNSUPPORTED_METHOD: 'unsupported-method',
|
|
58
|
+
UNSUPPORTED_VERSION: 'unsupported-version',
|
|
59
|
+
INCOMPATIBLE: 'contract-incompatible',
|
|
60
|
+
TRANSPORT_UNAVAILABLE: 'transport-unavailable',
|
|
61
|
+
TRANSPORT_TIMEOUT: 'transport-timeout',
|
|
62
|
+
TRANSPORT_HTTP_ERROR: 'transport-http-error',
|
|
63
|
+
REMOTE_ERROR: 'remote-error',
|
|
64
|
+
ALL_TRANSPORTS_FAILED: 'all-transports-failed',
|
|
65
|
+
HANDLER_THREW: 'handler-threw',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 兜底顺序(从快到慢):
|
|
70
|
+
* local —— 同一 Host 的 DSH 服务(零网络、首选)
|
|
71
|
+
* http —— 母体/原身 HTTP 契约端点
|
|
72
|
+
* file —— 同一文件系统的 outbox/inbox(HTTP 不可用/被防火墙拦)
|
|
73
|
+
* legacy —— 直接调用 3.1.x 的旧 DSH 服务键(接口包被替换/新端点不存在时)
|
|
74
|
+
*/
|
|
75
|
+
export const DEFAULT_TRANSPORT_ORDER = Object.freeze(['local', 'http', 'file', 'legacy'])
|
|
76
|
+
|
|
77
|
+
/** 3.1.x 及更早的旧服务键(只读兼容;不新增语义)。 */
|
|
78
|
+
export const LEGACY_SERVICE_KEYS = Object.freeze({
|
|
79
|
+
forker: 'fangForker',
|
|
80
|
+
decisionCenter: 'fangDecisionCenter',
|
|
81
|
+
sessionCatalog: 'fangSessionCatalog',
|
|
82
|
+
orchestrator: 'fang',
|
|
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
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
import { ERROR_CODES, PROTOCOL, PROTOCOL_VERSION } from './constants.js'
|
|
7
|
+
|
|
8
|
+
export function newId(prefix = 'msg') {
|
|
9
|
+
return `${prefix}_${crypto.randomUUID()}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 创建请求信封;未知字段由接收方忽略(兼容规则)。 */
|
|
13
|
+
export function createEnvelope({ method, params = null, id, source = '', instanceId = '', timeoutMs = 30_000, meta = {} } = {}) {
|
|
14
|
+
if (!method || typeof method !== 'string') throw new Error('method is required')
|
|
15
|
+
return {
|
|
16
|
+
protocol: PROTOCOL,
|
|
17
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
18
|
+
id: String(id ?? newId('req')),
|
|
19
|
+
method: String(method),
|
|
20
|
+
params: params ?? null,
|
|
21
|
+
meta: {
|
|
22
|
+
at: Date.now(),
|
|
23
|
+
deadline: Date.now() + Math.max(1, Number(timeoutMs) || 30_000),
|
|
24
|
+
source: String(source ?? ''),
|
|
25
|
+
instanceId: String(instanceId ?? ''),
|
|
26
|
+
...(meta && typeof meta === 'object' ? meta : {}),
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 校验请求/响应信封;返回 {ok, errors, envelope}。 */
|
|
32
|
+
export function validateEnvelope(value, { response = false } = {}) {
|
|
33
|
+
const errors = []
|
|
34
|
+
const raw = value && typeof value === 'object' ? value : {}
|
|
35
|
+
if (raw.protocol !== PROTOCOL) errors.push(`protocol-invalid:${String(raw.protocol)}`)
|
|
36
|
+
if (raw.protocolVersion !== PROTOCOL_VERSION) errors.push(`protocol-version-invalid:${String(raw.protocolVersion)}`)
|
|
37
|
+
if (!raw.id) errors.push('id-required')
|
|
38
|
+
if (response) {
|
|
39
|
+
if (typeof raw.ok !== 'boolean') errors.push('ok-required')
|
|
40
|
+
if (raw.ok === false && !raw.error?.code) errors.push('error-code-required')
|
|
41
|
+
} else if (!raw.method) {
|
|
42
|
+
errors.push('method-required')
|
|
43
|
+
}
|
|
44
|
+
return { ok: errors.length === 0, errors, envelope: raw }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createResponse({ id, result, error, meta = {} } = {}) {
|
|
48
|
+
const base = {
|
|
49
|
+
protocol: PROTOCOL,
|
|
50
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
51
|
+
id: String(id ?? newId('res')),
|
|
52
|
+
ok: !error,
|
|
53
|
+
meta: { at: Date.now(), ...(meta && typeof meta === 'object' ? meta : {}) },
|
|
54
|
+
}
|
|
55
|
+
if (error) return { ...base, error: normalizeError(error) }
|
|
56
|
+
return { ...base, result: result ?? null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeError(error, fallbackCode = ERROR_CODES.REMOTE_ERROR) {
|
|
60
|
+
if (!error) return { code: fallbackCode, message: String(fallbackCode), retryable: false }
|
|
61
|
+
if (typeof error === 'string') return { code: fallbackCode, message: error, retryable: false }
|
|
62
|
+
return {
|
|
63
|
+
code: String(error.code ?? fallbackCode),
|
|
64
|
+
message: String(error.message ?? error.error ?? fallbackCode),
|
|
65
|
+
retryable: error.retryable === true,
|
|
66
|
+
...(error.detail === undefined ? {} : { detail: error.detail }),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ContractTransportError extends Error {
|
|
71
|
+
constructor(code, message, { transport = '', detail = null, retryable = false } = {}) {
|
|
72
|
+
super(String(message ?? code))
|
|
73
|
+
this.name = 'ContractTransportError'
|
|
74
|
+
this.code = String(code)
|
|
75
|
+
this.transport = String(transport)
|
|
76
|
+
this.detail = detail
|
|
77
|
+
this.retryable = retryable === true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isCompatibleResponse(value) {
|
|
82
|
+
return validateEnvelope(value, { response: true }).ok
|
|
83
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @twinsearth/dsh-fang-contract
|
|
6
|
+
*
|
|
7
|
+
* 瓮城(dsh-Fang)↔ 房主(dsh-Master)的独立版本化互联 SDK。
|
|
8
|
+
* 设计目标:
|
|
9
|
+
* 1. 双方独立升级:语义化版本 + hello 协商 + 未知字段忽略 + 方法版本裁剪;
|
|
10
|
+
* 2. 互联互通保底:local → http → file → legacy 四级传输自动降级;
|
|
11
|
+
* 3. 接口包可独立替换:更新本包即升级接口架构,旧方法在 MAJOR 内保持可调用。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from './constants.js'
|
|
15
|
+
export * from './envelope.js'
|
|
16
|
+
export * from './negotiate.js'
|
|
17
|
+
export * from './server.js'
|
|
18
|
+
export * from './relay.js'
|
|
19
|
+
export * from './transports.js'
|
|
20
|
+
export * from './client.js'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CAPABILITIES,
|
|
6
|
+
CONTRACT_VERSION,
|
|
7
|
+
ERROR_CODES,
|
|
8
|
+
METHOD_VERSION,
|
|
9
|
+
METHODS,
|
|
10
|
+
MIN_COMPATIBLE_MAJOR,
|
|
11
|
+
PROTOCOL,
|
|
12
|
+
ROLES,
|
|
13
|
+
} from './constants.js'
|
|
14
|
+
|
|
15
|
+
export function parseVersion(value) {
|
|
16
|
+
const text = String(value ?? '').trim()
|
|
17
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(text)
|
|
18
|
+
if (!match) return null
|
|
19
|
+
return {
|
|
20
|
+
major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3] ?? 0), text,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compareVersions(a, b) {
|
|
25
|
+
const pa = parseVersion(a)
|
|
26
|
+
const pb = parseVersion(b)
|
|
27
|
+
if (!pa || !pb) return 0
|
|
28
|
+
if (pa.major !== pb.major) return pa.major - pb.major
|
|
29
|
+
if (pa.minor !== pb.minor) return pa.minor - pb.minor
|
|
30
|
+
return pa.patch - pb.patch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 标准 hello payload(服务端暴露;客户端据此协商)。 */
|
|
34
|
+
export function helloPayload({
|
|
35
|
+
role = 'mother',
|
|
36
|
+
instanceId = '',
|
|
37
|
+
version = CONTRACT_VERSION,
|
|
38
|
+
methods = Object.values(METHODS),
|
|
39
|
+
capabilities = CAPABILITIES,
|
|
40
|
+
transports = [],
|
|
41
|
+
startedAt = Date.now(),
|
|
42
|
+
} = {}) {
|
|
43
|
+
return {
|
|
44
|
+
protocol: PROTOCOL,
|
|
45
|
+
contractVersion: String(version),
|
|
46
|
+
role: ROLES.includes(String(role)) ? String(role) : 'fork',
|
|
47
|
+
instanceId: String(instanceId ?? ''),
|
|
48
|
+
startedAt: Number(startedAt) || Date.now(),
|
|
49
|
+
methods: [...new Set(methods.map(String))],
|
|
50
|
+
capabilities: [...new Set(capabilities.map(String))],
|
|
51
|
+
transports: [...new Set(transports.map(String))],
|
|
52
|
+
features: {
|
|
53
|
+
idempotency: true,
|
|
54
|
+
envelopes: true,
|
|
55
|
+
fileOutbox: true,
|
|
56
|
+
versionNegotiation: true,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 双向版本协商。
|
|
63
|
+
* @param {object} local 本端 helloPayload
|
|
64
|
+
* @param {object} remote 对端 helloPayload
|
|
65
|
+
* @returns {{ok:boolean, compatible:boolean, fallback:'legacy'|null, reason?:string, version?:string, methods:string[], capabilities:string[], features:object}}
|
|
66
|
+
*/
|
|
67
|
+
export function negotiate(local, remote) {
|
|
68
|
+
const lv = parseVersion(local?.contractVersion)
|
|
69
|
+
const rv = parseVersion(remote?.contractVersion)
|
|
70
|
+
if (local?.protocol !== PROTOCOL || remote?.protocol !== PROTOCOL) {
|
|
71
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'protocol-mismatch', methods: [], capabilities: [], features: {} }
|
|
72
|
+
}
|
|
73
|
+
if (!lv || !rv) {
|
|
74
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'version-invalid', methods: [], capabilities: [], features: {} }
|
|
75
|
+
}
|
|
76
|
+
if (rv.major < MIN_COMPATIBLE_MAJOR) {
|
|
77
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-old', methods: [], capabilities: [], features: {} }
|
|
78
|
+
}
|
|
79
|
+
if (rv.major > lv.major) {
|
|
80
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-new', methods: [], capabilities: [], features: {} }
|
|
81
|
+
}
|
|
82
|
+
const localMethods = new Set(Array.isArray(local.methods) ? local.methods : [])
|
|
83
|
+
const methods = (Array.isArray(remote.methods) ? remote.methods : []).filter((method) => localMethods.has(method))
|
|
84
|
+
// 裁剪:方法所需契约 minor 不能高于双方共同支持的 minor。
|
|
85
|
+
const commonMinor = Math.min(lv.minor, rv.minor)
|
|
86
|
+
const supported = methods.filter((method) => {
|
|
87
|
+
const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0')
|
|
88
|
+
return !methodVersion || methodVersion.minor <= commonMinor || methodVersion.major < lv.major
|
|
89
|
+
})
|
|
90
|
+
const localCaps = new Set(Array.isArray(local.capabilities) ? local.capabilities : [])
|
|
91
|
+
const capabilities = (Array.isArray(remote.capabilities) ? remote.capabilities : []).filter((cap) => localCaps.has(cap))
|
|
92
|
+
const version = `${Math.min(lv.major, rv.major)}.${commonMinor}.0`
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
compatible: true,
|
|
96
|
+
fallback: null,
|
|
97
|
+
version,
|
|
98
|
+
methods: supported,
|
|
99
|
+
capabilities,
|
|
100
|
+
features: { ...(local.features ?? {}), ...(remote.features ?? {}) },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function unsupportedMethodError(method) {
|
|
105
|
+
return { code: ERROR_CODES.UNSUPPORTED_METHOD, message: `unsupported method: ${String(method)}`, retryable: false }
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|