@twinsearth/dsh-master 3.2.10 → 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 +4 -0
- package/client.js +50 -0
- package/dist-host/contract/client.js +7 -0
- package/dist-host/contract/constants.js +16 -2
- package/dist-host/contract/index.js +1 -0
- package/dist-host/contract/relay.js +239 -0
- package/dist-host/cost/attribution.js +183 -0
- package/dist-host/cost/audit.js +163 -0
- package/dist-host/cost/budget.js +211 -0
- package/dist-host/cost/bus.js +95 -0
- package/dist-host/cost/cert-chain.js +100 -0
- package/dist-host/cost/experiment.js +132 -0
- package/dist-host/cost/fang-relay.js +329 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +345 -0
- package/dist-host/cost/ledger-report.js +193 -0
- package/dist-host/cost/ledger.js +401 -0
- package/dist-host/cost/metrics.js +198 -0
- package/dist-host/cost/milestones.js +209 -0
- package/dist-host/cost/modules/index.js +5 -0
- package/dist-host/cost/modules/manifests.js +130 -0
- package/dist-host/cost/policy.js +276 -0
- package/dist-host/cost/report.js +156 -0
- package/dist-host/cost/reuse-report.js +128 -0
- package/dist-host/cost/rsi.js +367 -0
- package/dist-host/cost/toolface.js +147 -0
- package/dist-host/cost/types.js +31 -0
- package/dist-host/kernel.js +28 -2
- package/dist-host/plugins/cost-optimizer.js +329 -27
- package/dist-host/plugins/superagent-router.js +104 -1
- package/dist-host/superagent/types.js +8 -0
- package/dist-worker/agent-loop.js +449 -7
- package/dist-worker/baseline-fit.js +66 -0
- package/dist-worker/budget-guard.js +93 -0
- package/dist-worker/ccs.js +99 -0
- package/dist-worker/cost-hooks.js +115 -0
- package/dist-worker/cost-modules.js +330 -0
- package/dist-worker/cost-spi.js +3 -0
- package/dist-worker/edg.js +372 -0
- package/dist-worker/gdr.js +159 -0
- package/dist-worker/index.js +80 -2
- package/dist-worker/llm-http.js +20 -0
- package/dist-worker/sink-http.js +1 -0
- package/dist-worker/spill.js +115 -0
- package/dist-worker/tool-batch.js +66 -0
- package/dist-worker/tool-compact.js +275 -0
- package/dist-worker/tool-disclosure.js +90 -0
- package/dist-worker/tools.js +16 -0
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +66 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
- package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
- package/docs/EXPERIMENT-M5-real-ab.md +87 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +2 -2
- package/src/contract/client.js +7 -0
- package/src/contract/constants.js +17 -2
- package/src/contract/index.js +1 -0
- package/src/contract/relay.js +223 -0
- package/src/cost/attribution.ts +186 -0
- package/src/cost/audit.ts +170 -0
- package/src/cost/budget.ts +226 -0
- package/src/cost/bus.ts +97 -0
- package/src/cost/cert-chain.ts +102 -0
- package/src/cost/experiment.ts +136 -0
- package/src/cost/fang-relay.ts +318 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +348 -0
- package/src/cost/ledger-report.ts +186 -0
- package/src/cost/ledger.ts +385 -0
- package/src/cost/metrics.ts +193 -0
- package/src/cost/milestones.ts +206 -0
- package/src/cost/modules/index.ts +13 -0
- package/src/cost/modules/manifests.ts +129 -0
- package/src/cost/policy.ts +276 -0
- package/src/cost/report.ts +158 -0
- package/src/cost/reuse-report.ts +131 -0
- package/src/cost/rsi.ts +364 -0
- package/src/cost/toolface.ts +151 -0
- package/src/cost/types.ts +298 -0
- package/src/kernel.ts +27 -2
- package/src/plugins/cost-optimizer.ts +313 -26
- package/src/plugins/superagent-router.ts +96 -1
- package/src/superagent/types.ts +70 -0
- package/worker/agent-loop.ts +444 -7
- package/worker/baseline-fit.ts +80 -0
- package/worker/budget-guard.ts +156 -0
- package/worker/ccs.ts +202 -0
- package/worker/cost-hooks.ts +133 -0
- package/worker/cost-modules.ts +356 -0
- package/worker/cost-spi.ts +283 -0
- package/worker/edg.ts +424 -0
- package/worker/gdr.ts +233 -0
- package/worker/index.ts +84 -3
- package/worker/llm-http.ts +18 -0
- package/worker/sink-http.ts +5 -2
- package/worker/spill.ts +133 -0
- package/worker/tool-batch.ts +124 -0
- package/worker/tool-compact.ts +317 -0
- package/worker/tool-disclosure.ts +131 -0
- package/worker/tools.ts +553 -531
- package/worker/types.ts +191 -0
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* 3.3.0 · M4 交付物:**证书链核对报告**(真实替换 → 证书 → 审计链 → 账本)。
|
|
7
|
+
*
|
|
8
|
+
* 一张证书要说清楚三件事,缺一件就不算"可回放":
|
|
9
|
+
*
|
|
10
|
+
* 1. **派发证据**:`viaNativeDispatcher === true` 且 `dispatched === true`(真的走了原生派发器);
|
|
11
|
+
* 2. **审计落链**:能在审计链里按 `taskId + step + edgeId` 找到对应记录,且 `verify()` 不断链;
|
|
12
|
+
* 3. **账本对账**:同一 `taskId + step` 的替换账本事件金额与证书金额一致。
|
|
13
|
+
*
|
|
14
|
+
* 任何一项不成立 ⇒ 该证书 `ok:false` 并给出 `issue`,报表整体 `ok:false`。
|
|
15
|
+
* 失败替换(`dispatched:false`)单列:它们是**自动回退的证据**,不是节省。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const num = (value) => {
|
|
19
|
+
const n = Number(value)
|
|
20
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* certificates?: Array<object>,
|
|
26
|
+
* entries?: Array<object>,
|
|
27
|
+
* audit?: {ok?: boolean, count?: number, records?: Array<object>},
|
|
28
|
+
* }} input
|
|
29
|
+
*/
|
|
30
|
+
export function buildCertChainReport(input = {}) {
|
|
31
|
+
const certificates = Array.isArray(input.certificates) ? input.certificates : []
|
|
32
|
+
const entries = Array.isArray(input.entries) ? input.entries : []
|
|
33
|
+
const auditOk = input?.audit?.ok !== false
|
|
34
|
+
const auditRecords = Array.isArray(input?.audit?.records) ? input.audit.records : []
|
|
35
|
+
|
|
36
|
+
// 替换账本事件:按 taskId+step 索引(同一步可能有多条,取合计)
|
|
37
|
+
const ledgerByKey = new Map()
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry?.meta?.kind !== 'substitution') continue
|
|
40
|
+
const key = `${String(entry.taskId)}|${Number(entry.step)}`
|
|
41
|
+
ledgerByKey.set(key, (ledgerByKey.get(key) ?? 0) + num(entry.savedTokens))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rows = certificates.map((cert) => {
|
|
45
|
+
const taskId = String(cert?.taskId ?? '')
|
|
46
|
+
const step = Number(cert?.step ?? -1)
|
|
47
|
+
const key = `${taskId}|${step}`
|
|
48
|
+
// 审计链只接受已派发的证书;因此 dispatched 缺省视为 true,显式 false 才是失败替换。
|
|
49
|
+
const dispatched = cert?.dispatched !== false
|
|
50
|
+
const native = cert?.viaNativeDispatcher === true
|
|
51
|
+
const ledgerSaved = ledgerByKey.get(key) ?? null
|
|
52
|
+
const certSaved = num(cert?.savedTokens)
|
|
53
|
+
const audited = auditRecords.some((record) => String(record?.payload?.edgeId ?? '') === String(cert?.edgeId ?? '')
|
|
54
|
+
&& String(record?.payload?.taskId ?? '') === taskId
|
|
55
|
+
&& Number(record?.payload?.step ?? -1) === step)
|
|
56
|
+
|
|
57
|
+
const issues = []
|
|
58
|
+
if (!native) issues.push('not-native-dispatcher')
|
|
59
|
+
if (dispatched && ledgerSaved === null) issues.push('missing-ledger-entry')
|
|
60
|
+
if (dispatched && ledgerSaved !== null && ledgerSaved !== certSaved) issues.push('ledger-mismatch')
|
|
61
|
+
if (dispatched && !audited && auditRecords.length > 0) issues.push('missing-audit-record')
|
|
62
|
+
if (dispatched && !auditOk) issues.push('audit-chain-broken')
|
|
63
|
+
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
edgeId: String(cert?.edgeId ?? ''),
|
|
66
|
+
toolName: String(cert?.toolName ?? ''),
|
|
67
|
+
replacedCallbackId: String(cert?.replacedCallbackId ?? ''),
|
|
68
|
+
taskId,
|
|
69
|
+
step,
|
|
70
|
+
dispatched,
|
|
71
|
+
viaNativeDispatcher: native,
|
|
72
|
+
certificateSavedTokens: certSaved,
|
|
73
|
+
ledgerSavedTokens: ledgerSaved,
|
|
74
|
+
audited,
|
|
75
|
+
issue: issues.length > 0 ? issues.join(',') : null,
|
|
76
|
+
/** 失败替换是「自动回退」的证据,不是缺陷也不是节省。 */
|
|
77
|
+
fallback: !dispatched,
|
|
78
|
+
ok: issues.length === 0,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const dispatchedRows = rows.filter((row) => row.dispatched)
|
|
83
|
+
const fallbackRows = rows.filter((row) => row.fallback)
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
ok: rows.every((row) => row.ok),
|
|
86
|
+
certificates: rows.length,
|
|
87
|
+
dispatched: dispatchedRows.length,
|
|
88
|
+
fallbacks: fallbackRows.length,
|
|
89
|
+
/** 证书声明的节省总额(只算派发成功的)。 */
|
|
90
|
+
savedTokens: dispatchedRows.reduce((sum, row) => sum + num(row.certificateSavedTokens), 0),
|
|
91
|
+
audit: Object.freeze({ ok: auditOk, records: Number(input?.audit?.count ?? auditRecords.length) }),
|
|
92
|
+
/** 自动回退证据:每条失败替换的原因。 */
|
|
93
|
+
fallbackReasons: Object.freeze(fallbackRows.map((row) => Object.freeze({ edgeId: row.edgeId, reason: String(row.issue ?? 'not-dispatched') }))),
|
|
94
|
+
rows: Object.freeze(rows),
|
|
95
|
+
discipline: Object.freeze({
|
|
96
|
+
/** 失败替换不计节省、不算替换成功。 */
|
|
97
|
+
fallbacksNeverCountAsSavings: fallbackRows.every((row) => num(row.certificateSavedTokens) === 0 || row.dispatched === false),
|
|
98
|
+
everyDispatchHasLedgerEntry: dispatchedRows.every((row) => row.ledgerSavedTokens !== null),
|
|
99
|
+
nativeDispatcherOnly: rows.every((row) => row.viaNativeDispatcher === true),
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* M5 实验台:**A/B 对照执行器**(control = ccs off / enforce = ccs enforce)。
|
|
7
|
+
*
|
|
8
|
+
* 设计原则(决定它能不能当证据):
|
|
9
|
+
* * 走**真实 Worker 代码路径**(工具注册表、A1 紧凑、A3 批量、B1/B2/B3 全都在);
|
|
10
|
+
* * 每组 ≥10 任务,**同一 corpus 同一顺序**,control 与 enforce 只差一个开关;
|
|
11
|
+
* * 输出**同表**:tokens/task 与成功率并列,绝不只报实验组;
|
|
12
|
+
* * **自报口径**:`harness:'local-dry-run'` 与 `runner` 字段写进结果 ——
|
|
13
|
+
* 用脚本化 LLM 跑的 dry run 不能冒充「真实 fork 实机」证据(G5 会因此继续被挡住)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const EXPERIMENT_HARNESS = Object.freeze({
|
|
17
|
+
dryRun: 'local-dry-run',
|
|
18
|
+
/** 真实 provider + 真实 Worker 代码路径(票据可以是合成的,但模型与工具是真的)。 */
|
|
19
|
+
realProvider: 'real-provider-real-worker',
|
|
20
|
+
realFork: 'real-fork',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const num = (value) => {
|
|
24
|
+
const n = Number(value)
|
|
25
|
+
return Number.isFinite(n) ? n : 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 单组执行:把 corpus 逐个跑完,收集指标。
|
|
30
|
+
*
|
|
31
|
+
* @param {{
|
|
32
|
+
* tasks: Array<{taskId: string, run: (task: object) => Promise<object>}>,
|
|
33
|
+
* group: 'control' | 'enforce',
|
|
34
|
+
* }} input
|
|
35
|
+
*/
|
|
36
|
+
export async function runGroup(input = {}) {
|
|
37
|
+
const tasks = Array.isArray(input.tasks) ? input.tasks : []
|
|
38
|
+
const rows = []
|
|
39
|
+
for (const task of tasks) {
|
|
40
|
+
const startedAt = Date.now()
|
|
41
|
+
let result
|
|
42
|
+
try {
|
|
43
|
+
result = await task.run(task)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
result = { ok: false, error: { code: 'harness-error', message: error instanceof Error ? error.message : String(error) } }
|
|
46
|
+
}
|
|
47
|
+
const elapsedMs = Date.now() - startedAt
|
|
48
|
+
const cost = result?.cost ?? {}
|
|
49
|
+
rows.push({
|
|
50
|
+
taskId: String(task.taskId),
|
|
51
|
+
ok: result?.ok === true,
|
|
52
|
+
tokens: num(result?.tokenUsage),
|
|
53
|
+
elapsedMs: num(result?.elapsedMs) || elapsedMs,
|
|
54
|
+
steps: Array.isArray(result?.steps) ? result.steps.length : 0,
|
|
55
|
+
a1: cost.compaction
|
|
56
|
+
? {
|
|
57
|
+
mode: cost.compaction.mode,
|
|
58
|
+
applied: cost.compaction.applied === true,
|
|
59
|
+
savedChars: num(cost.compaction.savedChars),
|
|
60
|
+
estimatedTokens: num(cost.compaction.estimatedTokens),
|
|
61
|
+
}
|
|
62
|
+
: null,
|
|
63
|
+
a3: cost.batch ? { taught: cost.batch.taught === true, corrections: num(cost.batch.corrections) } : null,
|
|
64
|
+
reuse: cost.ccs
|
|
65
|
+
? {
|
|
66
|
+
mode: cost.ccs.mode,
|
|
67
|
+
attempts: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.length : 0,
|
|
68
|
+
hits: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.filter((item) => item.hit === true).length : 0,
|
|
69
|
+
wouldSaveTokens: Array.isArray(cost.ccs.attempts) ? cost.ccs.attempts.reduce((sum, item) => sum + num(item.wouldSaveTokens), 0) : 0,
|
|
70
|
+
skippedModelCalls: num(cost.ccs.skippedModelCalls),
|
|
71
|
+
substitutions: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.length : 0,
|
|
72
|
+
dispatched: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.filter((cert) => cert.dispatched === true).length : 0,
|
|
73
|
+
failures: Array.isArray(cost.ccs.substitutions) ? cost.ccs.substitutions.filter((cert) => cert.dispatched === false).length : 0,
|
|
74
|
+
shadowCertificates: Array.isArray(cost.ccs.shadowCertificates) ? cost.ccs.shadowCertificates.length : 0,
|
|
75
|
+
}
|
|
76
|
+
: null,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
return { group: String(input.group ?? 'unknown'), tasks: rows }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 两组同表对比(成功率差值按 §10.1 的 2pp 门禁)。 */
|
|
83
|
+
export function compareGroups({ control, enforce, harness = EXPERIMENT_HARNESS.dryRun, runner = 'scripted-llm' } = {}) {
|
|
84
|
+
const avg = (rows) => (rows.length > 0 ? rows.reduce((sum, row) => sum + num(row.tokens), 0) / rows.length : null)
|
|
85
|
+
const rate = (rows) => (rows.length > 0 ? rows.filter((row) => row.ok === true).length / rows.length : null)
|
|
86
|
+
const ctl = Array.isArray(control?.tasks) ? control.tasks : []
|
|
87
|
+
const trt = Array.isArray(enforce?.tasks) ? enforce.tasks : []
|
|
88
|
+
const controlTokens = avg(ctl)
|
|
89
|
+
const enforceTokens = avg(trt)
|
|
90
|
+
const controlSuccess = rate(ctl)
|
|
91
|
+
const enforceSuccess = rate(trt)
|
|
92
|
+
const tokenDeltaPct = controlTokens !== null && enforceTokens !== null && controlTokens > 0
|
|
93
|
+
? (enforceTokens - controlTokens) / controlTokens
|
|
94
|
+
: null
|
|
95
|
+
const successDelta = controlSuccess !== null && enforceSuccess !== null ? enforceSuccess - controlSuccess : null
|
|
96
|
+
const reuse = trt.map((row) => row.reuse).filter(Boolean)
|
|
97
|
+
const a1 = trt.map((row) => row.a1).filter(Boolean)
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
harness,
|
|
100
|
+
runner,
|
|
101
|
+
/** 这条是给 G5 用的:dry-run 永远不足以当证据;真实 provider + 真实 worker 才算测量有效。 */
|
|
102
|
+
qualifiesForGate: harness === EXPERIMENT_HARNESS.realFork || harness === EXPERIMENT_HARNESS.realProvider,
|
|
103
|
+
table: [
|
|
104
|
+
{ group: 'control', samples: ctl.length, tokensPerTask: controlTokens, successRate: controlSuccess, elapsedMsMedian: median(ctl.map((row) => num(row.elapsedMs))) },
|
|
105
|
+
{ group: 'enforce', samples: trt.length, tokensPerTask: enforceTokens, successRate: enforceSuccess, elapsedMsMedian: median(trt.map((row) => num(row.elapsedMs))) },
|
|
106
|
+
],
|
|
107
|
+
delta: { tokensPerTask: controlTokens !== null && enforceTokens !== null ? enforceTokens - controlTokens : null, tokensPerTaskPct: tokenDeltaPct, successRate: successDelta },
|
|
108
|
+
a1: {
|
|
109
|
+
tasks: a1.length,
|
|
110
|
+
/** pplied 只反映**最后一次**压缩事件;真正判断贡献要看是否产生了估算节省。 */
|
|
111
|
+
tasksWithSavings: a1.filter((row) => num(row.estimatedTokens) > 0).length,
|
|
112
|
+
applied: a1.filter((row) => row.applied === true).length,
|
|
113
|
+
estimatedTokens: a1.reduce((sum, row) => sum + num(row.estimatedTokens), 0),
|
|
114
|
+
},
|
|
115
|
+
reuse: {
|
|
116
|
+
attempts: reuse.reduce((sum, row) => sum + num(row.attempts), 0),
|
|
117
|
+
hits: reuse.reduce((sum, row) => sum + num(row.hits), 0),
|
|
118
|
+
wouldSaveTokens: reuse.reduce((sum, row) => sum + num(row.wouldSaveTokens), 0),
|
|
119
|
+
skippedModelCalls: reuse.reduce((sum, row) => sum + num(row.skippedModelCalls), 0),
|
|
120
|
+
dispatched: reuse.reduce((sum, row) => sum + num(row.dispatched), 0),
|
|
121
|
+
failures: reuse.reduce((sum, row) => sum + num(row.failures), 0),
|
|
122
|
+
shadowCertificates: reuse.reduce((sum, row) => sum + num(row.shadowCertificates), 0),
|
|
123
|
+
},
|
|
124
|
+
/** 与 gate G5 同形,便于直接喂 `evaluateForkExperiment`。 */
|
|
125
|
+
gateShape: Object.freeze({
|
|
126
|
+
control: { tasks: ctl.map((row) => ({ taskId: row.taskId, tokens: row.tokens, success: row.ok })) },
|
|
127
|
+
enforce: { tasks: trt.map((row) => ({ taskId: row.taskId, tokens: row.tokens, success: row.ok })) },
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function median(values) {
|
|
133
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
134
|
+
if (sorted.length === 0) return null
|
|
135
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(0.5 * sorted.length) - 1)]
|
|
136
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* 3.3.1 · 瓮城回流出口(设计 §5.6,里程碑 M5)。
|
|
7
|
+
*
|
|
8
|
+
* 3.3.0 里这条通道是硬编码的 `{ target:'fang', available:false,
|
|
9
|
+
* reason:'fang-channel-not-ready' }`:接口位在,但没有实现。3.3.1 把它换成
|
|
10
|
+
* **真实探测 + 真实投递**:
|
|
11
|
+
*
|
|
12
|
+
* 1. `probe()`:用契约 SDK 的 `hello()` 协商,看对端在公共 minor 下是否宣告
|
|
13
|
+
* `fang.relay.publish` / `fang.relay.status`(contract 1.1 才有的方法);
|
|
14
|
+
* 宣告了再读一次 `fang.relay.status` 确认对端**真的在服务**,不是只挂方法名。
|
|
15
|
+
* 2. `enqueue()` / `flush()`:把 `cost:event` / `cost:degraded` /
|
|
16
|
+
* `cost:substitution` 批量投递,按事件 id 幂等,回执里的 `accepted` /
|
|
17
|
+
* `duplicates` / `cursor` 才是“送达”的唯一证据。
|
|
18
|
+
* 3. `status()`:同步返回**最近一次已知状态**(`probe()` 异步更新)。
|
|
19
|
+
*
|
|
20
|
+
* 两条硬约束(与 I1–I8 一致):
|
|
21
|
+
* * 不可用必须显式 `available:false` + 具体原因,**不得**把队列长度当送达;
|
|
22
|
+
* * 投递失败不重试成“假成功”:批次退回队列、`failed` 计数、状态转不可用,
|
|
23
|
+
* 并保留 `lastError`。
|
|
24
|
+
*
|
|
25
|
+
* 对端身份(`peer`)由对端在 `fang.relay.status` 里**自报**(如
|
|
26
|
+
* `dsh-fang-bundle` / `contract-server`),只用于记录“对端是谁”,不参与鉴权,
|
|
27
|
+
* 也不得被当成“已对接真实瓮城”的证据 —— 那需要 `peer === 'dsh-fang-bundle'`。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
|
|
32
|
+
import { METHODS, createContractClient } from '../contract/index.js'
|
|
33
|
+
|
|
34
|
+
/** 允许作为 M5 证据的对端标识(自报值白名单)。 */
|
|
35
|
+
export const RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server'])
|
|
36
|
+
|
|
37
|
+
/** 默认值:批量/队列/探测都有界,避免长跑进程无界增长。 */
|
|
38
|
+
export const RELAY_DEFAULTS = Object.freeze({
|
|
39
|
+
maxBatch: 32,
|
|
40
|
+
maxQueue: 500,
|
|
41
|
+
probeTtlMs: 60_000,
|
|
42
|
+
timeoutMs: 15_000,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function asString(value) { return String(value ?? '').trim() }
|
|
46
|
+
|
|
47
|
+
function clampInt(value, fallback, min, max) {
|
|
48
|
+
const n = Number(value)
|
|
49
|
+
if (!Number.isFinite(n)) return fallback
|
|
50
|
+
return Math.min(max, Math.max(min, Math.floor(n)))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function describeError(error) {
|
|
54
|
+
const code = asString(error?.code) || 'relay-error'
|
|
55
|
+
const message = asString(error?.message ?? error)
|
|
56
|
+
return message ? `${code}:${message.slice(0, 160)}` : code
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 创建回流出口。
|
|
61
|
+
*
|
|
62
|
+
* @param {{
|
|
63
|
+
* client?: object | null, // createContractClient() 的返回值;null ⇒ 明确不可用
|
|
64
|
+
* target?: string,
|
|
65
|
+
* now?: () => number,
|
|
66
|
+
* maxBatch?: number, maxQueue?: number, probeTtlMs?: number, timeoutMs?: number,
|
|
67
|
+
* logger?: object,
|
|
68
|
+
* }} [options]
|
|
69
|
+
*/
|
|
70
|
+
export function createFangRelay(options = {}) {
|
|
71
|
+
const client = options.client && typeof options.client === 'object' ? options.client : null
|
|
72
|
+
const target = asString(options.target) || 'fang'
|
|
73
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
74
|
+
const maxBatch = clampInt(options.maxBatch, RELAY_DEFAULTS.maxBatch, 1, 128)
|
|
75
|
+
const maxQueue = clampInt(options.maxQueue, RELAY_DEFAULTS.maxQueue, 1, 5_000)
|
|
76
|
+
const probeTtlMs = clampInt(options.probeTtlMs, RELAY_DEFAULTS.probeTtlMs, 1_000, 600_000)
|
|
77
|
+
const timeoutMs = clampInt(options.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000)
|
|
78
|
+
const logger = options.logger ?? null
|
|
79
|
+
|
|
80
|
+
const queue = []
|
|
81
|
+
const counters = { enqueued: 0, delivered: 0, duplicates: 0, failed: 0, dropped: 0, batches: 0, roundTrips: 0 }
|
|
82
|
+
let sequence = 0
|
|
83
|
+
let batchSequence = 0
|
|
84
|
+
let cursor = null
|
|
85
|
+
let lastError = null
|
|
86
|
+
let lastDeliveredAt = 0
|
|
87
|
+
let probing = null
|
|
88
|
+
// 没有客户端时开局即知不可用(不需要“探测中”这种灰色状态去掩盖)。
|
|
89
|
+
let state = Object.freeze({
|
|
90
|
+
available: false,
|
|
91
|
+
reason: client ? 'probe-pending' : 'relay-client-absent',
|
|
92
|
+
peer: null,
|
|
93
|
+
peerContractVersion: null,
|
|
94
|
+
transport: null,
|
|
95
|
+
probedAt: 0,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
function setState(next) {
|
|
99
|
+
state = Object.freeze({ ...state, probedAt: now(), ...next })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function status() {
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
target,
|
|
105
|
+
available: state.available === true,
|
|
106
|
+
reason: state.reason ?? null,
|
|
107
|
+
peer: state.peer ?? null,
|
|
108
|
+
peerContractVersion: state.peerContractVersion ?? null,
|
|
109
|
+
transport: state.transport ?? null,
|
|
110
|
+
delivered: counters.delivered,
|
|
111
|
+
failed: counters.failed,
|
|
112
|
+
queued: queue.length,
|
|
113
|
+
dropped: counters.dropped,
|
|
114
|
+
duplicates: counters.duplicates,
|
|
115
|
+
batches: counters.batches,
|
|
116
|
+
cursor,
|
|
117
|
+
lastError,
|
|
118
|
+
lastDeliveredAt,
|
|
119
|
+
probedAt: state.probedAt,
|
|
120
|
+
limits: { maxBatch, maxQueue, probeTtlMs, timeoutMs },
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 探测对端:hello 协商 → 方法裁剪判定 → 读一次 relay 状态确认真的可用。 */
|
|
125
|
+
async function probe({ force = false } = {}) {
|
|
126
|
+
if (!client) {
|
|
127
|
+
setState({ available: false, reason: 'relay-client-absent', peer: null })
|
|
128
|
+
return status()
|
|
129
|
+
}
|
|
130
|
+
if (!force && state.probedAt > 0 && now() - state.probedAt < probeTtlMs) return status()
|
|
131
|
+
if (probing) return probing
|
|
132
|
+
probing = (async () => {
|
|
133
|
+
try {
|
|
134
|
+
const hello = await client.hello(true)
|
|
135
|
+
if (!hello || hello.ok !== true) {
|
|
136
|
+
setState({ available: false, reason: `hello-failed:${asString(hello?.code) || 'unknown'}`, peer: null })
|
|
137
|
+
return status()
|
|
138
|
+
}
|
|
139
|
+
const methods = Array.isArray(hello.negotiation?.methods) ? hello.negotiation.methods : []
|
|
140
|
+
const peerContractVersion = asString(hello.hello?.contractVersion) || null
|
|
141
|
+
const offersRelay = methods.includes(METHODS.RELAY_PUBLISH) && methods.includes(METHODS.RELAY_STATUS)
|
|
142
|
+
if (!offersRelay) {
|
|
143
|
+
// 1.0 对端不会宣告 1.1 方法;这是“协议层没有回流”,不是传输故障。
|
|
144
|
+
setState({
|
|
145
|
+
available: false,
|
|
146
|
+
reason: `peer-contract-without-relay:${peerContractVersion ?? 'unknown'}`,
|
|
147
|
+
peer: null,
|
|
148
|
+
peerContractVersion,
|
|
149
|
+
transport: asString(hello.transport) || null,
|
|
150
|
+
})
|
|
151
|
+
return status()
|
|
152
|
+
}
|
|
153
|
+
const remote = await client.relayStatus({}, { timeoutMs })
|
|
154
|
+
if (!remote || remote.ok !== true) {
|
|
155
|
+
setState({
|
|
156
|
+
available: false,
|
|
157
|
+
reason: `relay-status-failed:${asString(remote?.code) || 'unknown'}`,
|
|
158
|
+
peer: null,
|
|
159
|
+
peerContractVersion,
|
|
160
|
+
transport: asString(hello.transport) || null,
|
|
161
|
+
})
|
|
162
|
+
return status()
|
|
163
|
+
}
|
|
164
|
+
const result = remote.response?.result ?? {}
|
|
165
|
+
cursor = Number.isFinite(Number(result.cursor)) ? Number(result.cursor) : cursor
|
|
166
|
+
setState({
|
|
167
|
+
available: true,
|
|
168
|
+
reason: null,
|
|
169
|
+
peer: asString(result.peer) || null,
|
|
170
|
+
peerContractVersion: asString(result.contractVersion) || peerContractVersion,
|
|
171
|
+
transport: asString(remote.transport ?? hello.transport) || null,
|
|
172
|
+
})
|
|
173
|
+
return status()
|
|
174
|
+
} catch (error) {
|
|
175
|
+
setState({ available: false, reason: `probe-threw:${describeError(error)}`, peer: null })
|
|
176
|
+
try { logger?.warn?.(`[cost-relay] probe failed: ${describeError(error)}`) } catch { /* ignore */ }
|
|
177
|
+
return status()
|
|
178
|
+
}
|
|
179
|
+
})()
|
|
180
|
+
try { return await probing } finally { probing = null }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 入队一条总线事件;不可用时不假装收下(记 dropped 并返回原因)。 */
|
|
184
|
+
function enqueue(event = {}) {
|
|
185
|
+
if (state.available !== true) {
|
|
186
|
+
counters.dropped += 1
|
|
187
|
+
return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown' }
|
|
188
|
+
}
|
|
189
|
+
const type = asString(event.type) || 'cost:event'
|
|
190
|
+
sequence += 1
|
|
191
|
+
const item = Object.freeze({
|
|
192
|
+
// id 稳定且唯一:重投同一批时对端按 id 去重,不会重复记账。
|
|
193
|
+
id: `cost-${type}-${sequence}-${Math.max(0, Math.floor(Number(event.at ?? now()) || 0))}`,
|
|
194
|
+
type,
|
|
195
|
+
at: Math.max(0, Math.floor(Number(event.at ?? now()) || 0)),
|
|
196
|
+
payload: event.payload && typeof event.payload === 'object' ? { ...event.payload } : {},
|
|
197
|
+
})
|
|
198
|
+
if (queue.length >= maxQueue) {
|
|
199
|
+
queue.shift()
|
|
200
|
+
counters.dropped += 1
|
|
201
|
+
}
|
|
202
|
+
queue.push(item)
|
|
203
|
+
counters.enqueued += 1
|
|
204
|
+
return { ok: true, id: item.id, queued: queue.length }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 投递一个批次;失败时批次退回队列并把状态转不可用(不吞错)。 */
|
|
208
|
+
async function flush() {
|
|
209
|
+
if (!client) return { ok: false, code: 'relay-client-absent', ...status() }
|
|
210
|
+
if (state.available !== true) return { ok: false, code: 'relay-unavailable', reason: state.reason ?? 'unknown', ...status() }
|
|
211
|
+
if (queue.length === 0) 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
|
+
} catch (error) {
|
|
219
|
+
outcome = { ok: false, code: 'relay-threw', error: describeError(error) }
|
|
220
|
+
}
|
|
221
|
+
counters.roundTrips += 1
|
|
222
|
+
if (outcome && outcome.ok === true) {
|
|
223
|
+
const result = outcome.response?.result ?? {}
|
|
224
|
+
const accepted = Math.max(0, Number(result.accepted ?? 0) || 0)
|
|
225
|
+
const duplicates = Math.max(0, Number(result.duplicates ?? 0) || 0)
|
|
226
|
+
// 只有对端回执才算送达:从队列头移除本次实际发送的批次。
|
|
227
|
+
queue.splice(0, batch.length)
|
|
228
|
+
counters.batches += 1
|
|
229
|
+
counters.delivered += accepted + duplicates
|
|
230
|
+
counters.duplicates += duplicates
|
|
231
|
+
if (Number.isFinite(Number(result.cursor))) cursor = Number(result.cursor)
|
|
232
|
+
lastDeliveredAt = now()
|
|
233
|
+
lastError = null
|
|
234
|
+
return { ok: true, delivered: accepted, duplicates, sent: batch.length, queued: queue.length, cursor, batchId }
|
|
235
|
+
}
|
|
236
|
+
const code = asString(outcome?.code) || 'publish-failed'
|
|
237
|
+
const detail = asString(outcome?.failures?.[0]?.code) || asString(outcome?.error)
|
|
238
|
+
lastError = detail && !detail.startsWith(code) ? `${code}:${detail}`.slice(0, 200) : (detail || code)
|
|
239
|
+
counters.failed += batch.length
|
|
240
|
+
setState({ available: false, reason: `publish-failed:${lastError}` })
|
|
241
|
+
try { logger?.warn?.(`[cost-relay] publish failed: ${lastError}`) } catch { /* ignore */ }
|
|
242
|
+
return { ok: false, code, error: lastError, sent: batch.length, queued: queue.length }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 挂到事件总线上:订阅者异常与投递异常都不影响主流程。 */
|
|
246
|
+
function attach(bus) {
|
|
247
|
+
if (!bus || typeof bus.subscribe !== 'function') return () => {}
|
|
248
|
+
return bus.subscribe((event) => {
|
|
249
|
+
try { enqueue(event) } catch { /* 回流不得影响主流程 */ }
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function reset() {
|
|
254
|
+
queue.length = 0
|
|
255
|
+
lastError = null
|
|
256
|
+
setState({ available: false, reason: 'probe-pending', peer: null })
|
|
257
|
+
return status()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Object.freeze({ target, probe, enqueue, flush, attach, status, reset, describe: status })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 从插件配置/环境装配契约客户端。
|
|
265
|
+
*
|
|
266
|
+
* 未配置任何传输时返回 null(**不**创建空客户端去假装探测过)。
|
|
267
|
+
*
|
|
268
|
+
* @param {object} ctx
|
|
269
|
+
* @param {{relay?: object, dispatch?: object}} [config]
|
|
270
|
+
* @param {{fetchImpl?: typeof fetch, env?: object}} [options]
|
|
271
|
+
*/
|
|
272
|
+
export function createRelayClient(ctx, config = {}, options = {}) {
|
|
273
|
+
const raw = config.relay && typeof config.relay === 'object' ? config.relay : {}
|
|
274
|
+
const dispatch = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
|
|
275
|
+
const env = options.env ?? process.env
|
|
276
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
277
|
+
const httpBaseUrl = asString(raw.httpBaseUrl) || asString(dispatch.motherUrl) || asString(env.DSH_FANG_CONTRACT_URL) || asString(env.DSH_MASTER_MOTHER_URL)
|
|
278
|
+
const httpToken = asString(raw.httpToken) || asString(dispatch.motherToken) || asString(env.DSH_FANG_CONTRACT_TOKEN)
|
|
279
|
+
const motherDataDir = asString(env.DSH_FANG_MOTHER)
|
|
280
|
+
const fileDir = asString(raw.fileDir) || asString(dispatch.contractFileDir) || (motherDataDir ? path.join(motherDataDir, 'contract-outbox') : '')
|
|
281
|
+
const localService = (() => {
|
|
282
|
+
try { return ctx && typeof ctx.get === 'function' ? ctx.get('fangContract') : null } catch { return null }
|
|
283
|
+
})()
|
|
284
|
+
const enabled = raw.enabled === true || Boolean(httpBaseUrl) || Boolean(fileDir) || Boolean(localService)
|
|
285
|
+
if (!enabled) return null
|
|
286
|
+
if (raw.enabled === false) return null
|
|
287
|
+
return createContractClient({
|
|
288
|
+
ctx,
|
|
289
|
+
config: {
|
|
290
|
+
httpBaseUrl,
|
|
291
|
+
httpToken,
|
|
292
|
+
httpHeaders: raw.httpHeaders && typeof raw.httpHeaders === 'object' ? raw.httpHeaders : undefined,
|
|
293
|
+
fileDir,
|
|
294
|
+
timeoutMs: clampInt(raw.timeoutMs, RELAY_DEFAULTS.timeoutMs, 1_000, 300_000),
|
|
295
|
+
fileTimeoutMs: clampInt(raw.fileTimeoutMs, 30_000, 1_000, 300_000),
|
|
296
|
+
order: Array.isArray(raw.order) ? raw.order : undefined,
|
|
297
|
+
},
|
|
298
|
+
fetchImpl,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 一次性装配:配置 → 客户端 → 出口。返回的 `client` 为 null 表示没有任何
|
|
304
|
+
* 可用传输(此时出口状态是 `relay-client-absent`,不是“探测中”)。
|
|
305
|
+
*/
|
|
306
|
+
export function createFangRelayFromContext(ctx, config = {}, options = {}) {
|
|
307
|
+
const client = createRelayClient(ctx, config, options)
|
|
308
|
+
const relay = createFangRelay({
|
|
309
|
+
client,
|
|
310
|
+
now: options.now,
|
|
311
|
+
maxBatch: config.relay?.maxBatch,
|
|
312
|
+
maxQueue: config.relay?.maxQueue,
|
|
313
|
+
probeTtlMs: config.relay?.probeTtlMs,
|
|
314
|
+
timeoutMs: config.relay?.timeoutMs,
|
|
315
|
+
logger: options.logger,
|
|
316
|
+
})
|
|
317
|
+
return { client, relay }
|
|
318
|
+
}
|