@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.
- package/README.md +3 -1
- package/client.js +1 -1
- 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 +244 -0
- package/dist-host/cost/fang-relay.js +329 -0
- package/dist-host/cost/index.js +35 -2
- package/dist-host/cost/milestones.js +44 -6
- package/dist-host/kernel.js +1 -1
- package/dist-host/plugins/cost-optimizer.js +57 -5
- package/dist-host/plugins/superagent-router.js +18 -2
- package/docs/ARCHITECTURE.md +8 -5
- package/docs/CHANGELOG.md +31 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +25 -19
- package/docs/EXPERIMENT-M5-fang-relay.md +180 -0
- package/docs/EXPERIMENT-M5-real-ab.md +6 -1
- 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 +229 -0
- package/src/cost/fang-relay.ts +318 -0
- package/src/cost/index.ts +34 -2
- package/src/cost/milestones.ts +41 -5
- package/src/kernel.ts +1 -1
- package/src/plugins/cost-optimizer.ts +48 -6
- package/src/plugins/superagent-router.ts +16 -2
|
@@ -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
|
+
}
|
package/src/cost/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createAuditChain } from './audit.ts'
|
|
|
17
17
|
import { createAttribution } from './attribution.ts'
|
|
18
18
|
import { createCostBus } from './bus.ts'
|
|
19
19
|
import { createHostSurfaceAdapter } from './host-surface.ts'
|
|
20
|
+
import { createFangRelay } from './fang-relay.ts'
|
|
20
21
|
import { buildSavingsReport, classifyEntry } from './report.ts'
|
|
21
22
|
import { buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP } from './metrics.ts'
|
|
22
23
|
import { MODULE_MANIFESTS, MODULE_IDS, validateManifests } from './modules/manifests.ts'
|
|
@@ -24,8 +25,9 @@ import { buildLedgerReport, formatLedgerReport, parseLedgerJsonl } from './ledge
|
|
|
24
25
|
import { buildToolFaceReport, planToolFace } from './toolface.ts'
|
|
25
26
|
import { buildReuseReport, formatReuseReport } from './reuse-report.ts'
|
|
26
27
|
|
|
27
|
-
export { createLedger, createBudget, createPolicy, createAuditChain, createAttribution, createCostBus, createHostSurfaceAdapter, buildSavingsReport, classifyEntry, buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP, MODULE_MANIFESTS, MODULE_IDS, validateManifests, buildLedgerReport, formatLedgerReport, parseLedgerJsonl, buildToolFaceReport, planToolFace, buildReuseReport, formatReuseReport }
|
|
28
|
+
export { createLedger, createBudget, createPolicy, createAuditChain, createAttribution, createCostBus, createHostSurfaceAdapter, createFangRelay, buildSavingsReport, classifyEntry, buildMetricsReport, METRIC_KEYS, SUCCESS_REGRESSION_PP, MODULE_MANIFESTS, MODULE_IDS, validateManifests, buildLedgerReport, formatLedgerReport, parseLedgerJsonl, buildToolFaceReport, planToolFace, buildReuseReport, formatReuseReport }
|
|
28
29
|
export * from './types.ts'
|
|
30
|
+
export { RELAY_PEERS, RELAY_DEFAULTS, createFangRelayFromContext, createRelayClient } from './fang-relay.ts'
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* @param {{
|
|
@@ -34,6 +36,7 @@ export * from './types.ts'
|
|
|
34
36
|
* budgetLimits?: Array<{unit:string, limitTokens?:number, limitCost?:number, onExhaust?:string, degradeTo?:string}>,
|
|
35
37
|
* degradeModels?: string[],
|
|
36
38
|
* now?: () => number, maxEntries?: number, maxRecords?: number,
|
|
39
|
+
* relay?: object, relayClient?: object|null, relayOptions?: object, relayAutoAttach?: boolean,
|
|
37
40
|
* }} [options]
|
|
38
41
|
*/
|
|
39
42
|
export function createCostSystem(options = {}) {
|
|
@@ -50,6 +53,18 @@ export function createCostSystem(options = {}) {
|
|
|
50
53
|
})
|
|
51
54
|
const attribution = createAttribution({ ledger, now })
|
|
52
55
|
const bus = createCostBus({ now, maxEvents: options.maxBusEvents, relays: options.relays })
|
|
56
|
+
/**
|
|
57
|
+
* §5.6 / M5:瓮城回流出口。
|
|
58
|
+
* 传入 `relay`(已有适配器)则复用;否则用 `relayClient` 现造一个。
|
|
59
|
+
* 没有客户端时出口状态是 `relay-client-absent` —— 显式不可用,不是“探测中”。
|
|
60
|
+
*/
|
|
61
|
+
const relay = options.relay && typeof options.relay.status === 'function'
|
|
62
|
+
? options.relay
|
|
63
|
+
: createFangRelay({ client: options.relayClient ?? null, now, ...(options.relayOptions && typeof options.relayOptions === 'object' ? options.relayOptions : {}), logger: options.logger })
|
|
64
|
+
// 默认把出口挂到总线;测试可关掉自动挂载,手动投递。
|
|
65
|
+
if (options.relayAutoAttach !== false && typeof relay.attach === 'function') {
|
|
66
|
+
try { relay.attach(bus) } catch { /* 回流挂载失败不影响记账 */ }
|
|
67
|
+
}
|
|
53
68
|
/** 宿主会话工具面适配器(可选;默认关闭,不依赖任何第三方插件)。 */
|
|
54
69
|
const hostSurface = createHostSurfaceAdapter({
|
|
55
70
|
ledger,
|
|
@@ -270,6 +285,21 @@ export function createCostSystem(options = {}) {
|
|
|
270
285
|
return bus.subscribe(listener)
|
|
271
286
|
}
|
|
272
287
|
|
|
288
|
+
/** §5.6:探测回流对端(hello 协商 + relay 状态),结果并入 `status().relay`。 */
|
|
289
|
+
async function relayProbe(input = {}) {
|
|
290
|
+
return await relay.probe({ force: input.force === true })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** §5.6:投递当前队列里的回流事件;返回回执摘要(不吞错,不谎报送达)。 */
|
|
294
|
+
async function relayFlush() {
|
|
295
|
+
if (typeof relay.flush !== 'function') return { ok: false, code: 'relay-flush-unsupported' }
|
|
296
|
+
return await relay.flush()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function relayStatus() {
|
|
300
|
+
return relay.status()
|
|
301
|
+
}
|
|
302
|
+
|
|
273
303
|
function status() {
|
|
274
304
|
const ledgerSnapshot = ledger.snapshot()
|
|
275
305
|
const auditVerify = audit.verify()
|
|
@@ -293,6 +323,8 @@ export function createCostSystem(options = {}) {
|
|
|
293
323
|
unattributedProjectedTotal: attributionSnapshot.unattributedProjectedTotal,
|
|
294
324
|
},
|
|
295
325
|
bus: { count: busSnapshot.count, dropped: busSnapshot.dropped, subscribers: busSnapshot.subscribers, relays: busSnapshot.relays },
|
|
326
|
+
/** §5.6 / M5:瓮城回流出口的真实状态(可达则 available:true + 对端自报 peer)。 */
|
|
327
|
+
relay: relay.status(),
|
|
296
328
|
degraded: {
|
|
297
329
|
modules: [...degradedByModule.values()].map((item) => ({ ...item, phases: { ...item.phases } })),
|
|
298
330
|
occurrences: [...degradedByModule.values()].reduce((sum, item) => sum + item.occurrences, 0),
|
|
@@ -312,5 +344,5 @@ export function createCostSystem(options = {}) {
|
|
|
312
344
|
})
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
return Object.freeze({ ledger, budget, policy, audit, attribution, bus, hostSurface, recordUsage, authorize, attribute, attributeTotal, substitute, trace, degraded, query, replay, savingsReport, metrics, ledgerReport, subscribe, status })
|
|
347
|
+
return Object.freeze({ ledger, budget, policy, audit, attribution, bus, hostSurface, relay, recordUsage, authorize, attribute, attributeTotal, substitute, trace, degraded, query, replay, savingsReport, metrics, ledgerReport, subscribe, status, relayProbe, relayFlush, relayStatus })
|
|
316
348
|
}
|
package/src/cost/milestones.ts
CHANGED
|
@@ -28,6 +28,13 @@ export const M3_MIN_SAMPLES = 20
|
|
|
28
28
|
/** M4 放行前要求的最小守卫准确率(与 ENFORCE_GATE.minAccuracy 一致)。 */
|
|
29
29
|
export const M3_MIN_ACCURACY = 0.98
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* M5 回流放行条件:至少 3 条事件拿到对端回执,且对端自报身份在白名单内。
|
|
33
|
+
* 自环(同一进程里服务端+客户端)与未知 peer 一律不算达标。
|
|
34
|
+
*/
|
|
35
|
+
export const M5_MIN_RELAY_EVENTS = 3
|
|
36
|
+
export const M5_RELAY_PEERS = Object.freeze(['dsh-fang-bundle', 'contract-server'])
|
|
37
|
+
|
|
31
38
|
export const MILESTONES = Object.freeze([
|
|
32
39
|
Object.freeze({
|
|
33
40
|
id: 'M1',
|
|
@@ -79,13 +86,16 @@ export const MILESTONES = Object.freeze([
|
|
|
79
86
|
* files?: string[], // 仓库里实际存在的文件(相对路径)
|
|
80
87
|
* guard?: {samples?: number, accuracy?: number}, // M3 的守卫准确率数据(来自 shadow 报告)
|
|
81
88
|
* forkExperiment?: {ok?: boolean}, // M4 的成功率对照(来自 gate 的 G5)
|
|
82
|
-
* fangRelay?: {available?: boolean
|
|
89
|
+
* fangRelay?: {available?: boolean, peer?: string, events?: number,
|
|
90
|
+
* sameProcess?: boolean, transport?: string, contractVersion?: string,
|
|
91
|
+
* reason?: string}, // M5 的回流对端证据(独立进程实测)
|
|
83
92
|
* }} evidence
|
|
84
93
|
*/
|
|
85
94
|
export function evaluateMilestones(evidence = {}) {
|
|
86
95
|
const files = new Set(Array.isArray(evidence.files) ? evidence.files : [])
|
|
87
96
|
const rows = []
|
|
88
97
|
const done = new Set()
|
|
98
|
+
let relayEvidence = null
|
|
89
99
|
|
|
90
100
|
for (const milestone of MILESTONES) {
|
|
91
101
|
const missingImplementation = milestone.implementation.filter((file) => !files.has(file))
|
|
@@ -118,10 +128,30 @@ export function evaluateMilestones(evidence = {}) {
|
|
|
118
128
|
if (evidence?.forkExperiment?.ok === true) effect = 'verified'
|
|
119
129
|
else reasons.push('effect-needs-fork-success-comparison')
|
|
120
130
|
} else if (milestone.id === 'M5') {
|
|
121
|
-
//
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
// 回流必须有**对端回执**才算达标:可用 + 非自环 + 对端自报身份在白名单 + 至少 3 条事件。
|
|
132
|
+
const relay = evidence?.fangRelay && typeof evidence.fangRelay === 'object' ? evidence.fangRelay : null
|
|
133
|
+
const available = relay?.available === true
|
|
134
|
+
const peer = String(relay?.peer ?? '')
|
|
135
|
+
const events = Math.max(0, Number(relay?.events ?? 0) || 0)
|
|
136
|
+
if (!available) {
|
|
137
|
+
reasons.push(`fang-relay-unavailable:${String(relay?.reason ?? 'not-provided')}`)
|
|
138
|
+
} else if (relay?.sameProcess === true) {
|
|
139
|
+
reasons.push('fang-relay-self-loop')
|
|
140
|
+
} else if (!M5_RELAY_PEERS.includes(peer)) {
|
|
141
|
+
reasons.push(`fang-relay-peer-invalid:${peer || 'missing'}`)
|
|
142
|
+
} else if (events < M5_MIN_RELAY_EVENTS) {
|
|
143
|
+
reasons.push(`fang-relay-no-receipts(events=${events})`)
|
|
144
|
+
} else {
|
|
145
|
+
effect = 'verified'
|
|
146
|
+
}
|
|
147
|
+
relayEvidence = Object.freeze({
|
|
148
|
+
available,
|
|
149
|
+
peer: peer || null,
|
|
150
|
+
events,
|
|
151
|
+
sameProcess: relay?.sameProcess === true,
|
|
152
|
+
transport: relay?.transport ?? null,
|
|
153
|
+
contractVersion: relay?.contractVersion ?? relay?.peerContractVersion ?? null,
|
|
154
|
+
})
|
|
125
155
|
}
|
|
126
156
|
}
|
|
127
157
|
|
|
@@ -141,6 +171,12 @@ export function evaluateMilestones(evidence = {}) {
|
|
|
141
171
|
effect,
|
|
142
172
|
status,
|
|
143
173
|
reasons,
|
|
174
|
+
/** M5:回流对端证据(peer 自报 + 事件数 + 是否自环);其它阶段为 null。 */
|
|
175
|
+
relay: relayEvidence,
|
|
176
|
+
/** 剩余缺口说明(不隐藏)。 */
|
|
177
|
+
notes: milestone.id === 'M5' && relayEvidence?.peer === 'contract-server'
|
|
178
|
+
? ['peer=contract-server:契约对端独立进程实测,dsh-fang-bundle 采用 contract 1.1 后应变为 peer=dsh-fang-bundle']
|
|
179
|
+
: [],
|
|
144
180
|
}))
|
|
145
181
|
if (status === 'done') done.add(milestone.id)
|
|
146
182
|
}
|
package/src/kernel.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
roleAllowed,
|
|
29
29
|
safeGet,
|
|
30
30
|
} from '../kernel.ts'
|
|
31
|
-
import { createCostSystem, DEFAULT_BUDGET_LIMITS } from '../cost/index.ts'
|
|
31
|
+
import { createCostSystem, createFangRelayFromContext, DEFAULT_BUDGET_LIMITS } from '../cost/index.ts'
|
|
32
32
|
|
|
33
33
|
export const name = 'dsh-master-cost-optimizer'
|
|
34
34
|
export const inject = ['dshMaster']
|
|
@@ -53,6 +53,10 @@ function finiteNonNegative(value) {
|
|
|
53
53
|
* enabled?: boolean, mode?: string, targetSavingsRatio?: number,
|
|
54
54
|
* budget?: Array<{unit: string, limitTokens: number, onExhaust?: string}>,
|
|
55
55
|
* enforceAllowlist?: string[],
|
|
56
|
+
* relay?: {enabled?: boolean, httpBaseUrl?: string, httpToken?: string, fileDir?: string,
|
|
57
|
+
* timeoutMs?: number, maxBatch?: number, maxQueue?: number, probeTtlMs?: number,
|
|
58
|
+
* autoFlushMs?: number},
|
|
59
|
+
* dispatch?: {motherUrl?: string, motherToken?: string, contractFileDir?: string},
|
|
56
60
|
* }} [config]
|
|
57
61
|
*/
|
|
58
62
|
export function apply(ctx, config = {}) {
|
|
@@ -70,6 +74,12 @@ export function apply(ctx, config = {}) {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const targetSavingsRatio = finiteNonNegative(config.targetSavingsRatio)
|
|
77
|
+
/**
|
|
78
|
+
* §5.6 / M5:瓮城回流出口。
|
|
79
|
+
* 传输按「插件配置 relay → dispatch 配置 → 环境变量 DSH_FANG_*」装配;
|
|
80
|
+
* 一个都没有时不造客户端,出口状态显式 `relay-client-absent`。
|
|
81
|
+
*/
|
|
82
|
+
const relayWiring = createFangRelayFromContext(ctx, config, { logger: ctx?.logger })
|
|
73
83
|
const system = createCostSystem({
|
|
74
84
|
mode: config.mode,
|
|
75
85
|
allowlist: config.enforceAllowlist ?? config.allowlist,
|
|
@@ -80,9 +90,21 @@ export function apply(ctx, config = {}) {
|
|
|
80
90
|
hostSurface: config.hostSurface && typeof config.hostSurface === 'object'
|
|
81
91
|
? { enabled: config.hostSurface.enabled === true, source: config.hostSurface.source }
|
|
82
92
|
: undefined,
|
|
93
|
+
relay: relayWiring.relay,
|
|
94
|
+
logger: ctx?.logger,
|
|
83
95
|
})
|
|
84
96
|
const policy = system.policy.describe()
|
|
85
97
|
|
|
98
|
+
// M5:加载后异步探测一次回流对端(不阻塞 apply;没有客户端时立即返回 absent)。
|
|
99
|
+
try { void Promise.resolve(system.relayProbe()).catch(() => {}) } catch { /* 探测失败不影响主流程 */ }
|
|
100
|
+
/** M5:回流自动投递;没有客户端或 autoFlushMs=0 时不开定时器。 */
|
|
101
|
+
const autoFlushMs = Math.max(0, Number(config.relay?.autoFlushMs ?? 2_000) || 0)
|
|
102
|
+
let relayTimer = null
|
|
103
|
+
if (relayWiring.client && autoFlushMs > 0) {
|
|
104
|
+
relayTimer = setInterval(() => { try { void system.relayFlush().catch(() => {}) } catch { /* ignore */ } }, autoFlushMs)
|
|
105
|
+
try { relayTimer.unref?.() } catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
// 3.2.x 兼容计数器(任务级实测汇总;系统层另有账本,两者口径不同、互不覆盖)。
|
|
87
109
|
const metrics = {
|
|
88
110
|
tasks: 0,
|
|
@@ -95,7 +117,7 @@ export function apply(ctx, config = {}) {
|
|
|
95
117
|
|
|
96
118
|
const service = {
|
|
97
119
|
id: SERVICE.cost,
|
|
98
|
-
version: '3.3.
|
|
120
|
+
version: '3.3.2',
|
|
99
121
|
role,
|
|
100
122
|
get mode() {
|
|
101
123
|
return system.policy.describe().mode
|
|
@@ -315,12 +337,24 @@ export function apply(ctx, config = {}) {
|
|
|
315
337
|
substitutions(filter = {}) {
|
|
316
338
|
return system.audit.substitutions(filter)
|
|
317
339
|
},
|
|
340
|
+
/** M5:瓮城回流出口状态(只读;不可用时必须带具体原因)。 */
|
|
341
|
+
relayStatus() {
|
|
342
|
+
return system.relayStatus()
|
|
343
|
+
},
|
|
344
|
+
/** M5:立即探测回流对端(hello 协商 + relay 状态),返回最新状态。 */
|
|
345
|
+
async relayProbe(input = {}) {
|
|
346
|
+
return await system.relayProbe(input)
|
|
347
|
+
},
|
|
348
|
+
/** M5:立即投递队列中的回流事件;回执里的数字才是送达证据。 */
|
|
349
|
+
async relayFlush() {
|
|
350
|
+
return await system.relayFlush()
|
|
351
|
+
},
|
|
318
352
|
status() {
|
|
319
353
|
const current = system.policy.describe()
|
|
320
354
|
return {
|
|
321
355
|
ok: true,
|
|
322
356
|
id: 'cost-optimizer',
|
|
323
|
-
version: '3.3.
|
|
357
|
+
version: '3.3.2',
|
|
324
358
|
role,
|
|
325
359
|
mode: current.mode,
|
|
326
360
|
requestedMode: current.requestedMode,
|
|
@@ -340,8 +374,10 @@ export function apply(ctx, config = {}) {
|
|
|
340
374
|
attribution: system.attribution.snapshot(),
|
|
341
375
|
attributionSummary: system.status().attributionSummary,
|
|
342
376
|
bus: system.bus.snapshot(1),
|
|
343
|
-
|
|
344
|
-
|
|
377
|
+
/** §5.6 / M5:瓮城回流出口(可达 ⇒ available:true + 对端自报 peer;否则带原因)。 */
|
|
378
|
+
relay: system.relayStatus(),
|
|
379
|
+
/** §5.6:内部事件类型(冒号命名)与对外出口状态。 */
|
|
380
|
+
events: { types: ['cost:event', 'cost:degraded', 'cost:substitution'], relays: system.bus.snapshot(1).relays },
|
|
345
381
|
degraded: system.status().degraded,
|
|
346
382
|
surfaces: system.status().surfaces,
|
|
347
383
|
hostSurface: system.hostSurface.status(),
|
|
@@ -360,12 +396,18 @@ export function apply(ctx, config = {}) {
|
|
|
360
396
|
doubleCountGuard: report.doubleCountGuard,
|
|
361
397
|
}
|
|
362
398
|
})(),
|
|
363
|
-
note: '3.3.
|
|
399
|
+
note: '3.3.2:系统层记账/预算/灰度/审计 + A1 工具输出紧凑化(默认 shadow)+ 瓮城回流出口(contract 1.1.1 真实投递,回执计数修复);工具面 A2–A3 与决策复用 B1–B4 已接入(shadow 默认)。',
|
|
364
400
|
}
|
|
365
401
|
},
|
|
366
402
|
}
|
|
367
403
|
|
|
368
404
|
try { ctx.effect(() => ctx.provide(SERVICE.cost, service)) } catch { /* fake ctx */ }
|
|
405
|
+
try {
|
|
406
|
+
ctx.effect(() => () => {
|
|
407
|
+
if (relayTimer) clearInterval(relayTimer)
|
|
408
|
+
relayTimer = null
|
|
409
|
+
})
|
|
410
|
+
} catch { /* fake ctx */ }
|
|
369
411
|
// 3.3.0 §11:可选子服务(主服务形态不变;消费方必须探测,不能假定存在)。
|
|
370
412
|
const optionalFaces = {
|
|
371
413
|
[OPTIONAL_SERVICE.costLedger]: Object.freeze({
|
|
@@ -312,6 +312,20 @@ export function apply(ctx, config = {}) {
|
|
|
312
312
|
} catch { return null }
|
|
313
313
|
})()
|
|
314
314
|
const tickets = id === null ? [...workerEvents.keys()] : [id]
|
|
315
|
+
/**
|
|
316
|
+
* §5.6 / M5:回流出口状态来自成本服务(3.3.1 起是真实探测,不再是硬编码)。
|
|
317
|
+
* 没有成本服务时如实给 `cost-service-unavailable`,不编造“未就绪”原因。
|
|
318
|
+
*/
|
|
319
|
+
const relayBlock = (() => {
|
|
320
|
+
try {
|
|
321
|
+
const cost = safeGet(ctx, SERVICE.cost)
|
|
322
|
+
if (cost && typeof cost.relayStatus === 'function') {
|
|
323
|
+
const relay = cost.relayStatus()
|
|
324
|
+
if (relay && typeof relay === 'object') return relay
|
|
325
|
+
}
|
|
326
|
+
} catch { /* 回流状态读取失败不阻塞成本摘要 */ }
|
|
327
|
+
return { target: 'fang', available: false, reason: 'cost-service-unavailable' }
|
|
328
|
+
})()
|
|
315
329
|
const rows = []
|
|
316
330
|
const totals = { events: 0, degraded: 0, substitutions: 0, estimated: 0, savedTokens: 0 }
|
|
317
331
|
for (const key of tickets) {
|
|
@@ -345,8 +359,8 @@ export function apply(ctx, config = {}) {
|
|
|
345
359
|
totals,
|
|
346
360
|
/* §11:只读成本块(A 单价项 / B 跳过调用项分列 + 命中率),面板只读消费。 */
|
|
347
361
|
service: serviceBlock,
|
|
348
|
-
/* §5.6
|
|
349
|
-
relay:
|
|
362
|
+
/* §5.6 / M5:瓮城回流出口真实状态(不可用必须显式带原因)。 */
|
|
363
|
+
relay: relayBlock,
|
|
350
364
|
}
|
|
351
365
|
}
|
|
352
366
|
|