@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang v2.6.1 — 母体侧 Adapter v3 客户端(契约:docs/PROTOCOL-V3.md §5)。
|
|
4
|
+
//
|
|
5
|
+
// 把「协议协商 + 状态快照 + SSE 事件流 + 断线重连 + cursor 续传 + 幂等命令」封成一个
|
|
6
|
+
// ForkClient。母体(Orchestrator / 未来 Forker 监督器)只接触方法,不碰 HTTP/SSE 细节。
|
|
7
|
+
//
|
|
8
|
+
// 失败语义(刻意与 remote.mjs 一致):**永不抛出**,全部降级为 `{ok:false, ...}`;
|
|
9
|
+
// 网络/超时类失败按指数退避无限重连,401(token 错误)是致命失败、不重试。
|
|
10
|
+
import { RECONNECT_DEFAULTS, createSseParser, negotiateProtocol, newCommandId } from './adapter-protocol.mjs'
|
|
11
|
+
import { classifyRemoteFailure, createRemote } from './remote.mjs'
|
|
12
|
+
|
|
13
|
+
const msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
|
|
14
|
+
|
|
15
|
+
const positiveMs = (value, fallback) => {
|
|
16
|
+
const n = Number(value)
|
|
17
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {string} [options.url] 分身 web URL(可自带 `?token=`)
|
|
23
|
+
* @param {string} [options.token] DSH web token(也用于 SSE 的 ?token=)
|
|
24
|
+
* @param {string} [options.adapterToken] v3 Bearer token(不落 URL)
|
|
25
|
+
* @param {Function} [options.fetchImpl] 可注入 fetch(测试用)
|
|
26
|
+
* @param {number} [options.timeoutMs]
|
|
27
|
+
* @param {string} [options.prefix]
|
|
28
|
+
* @param {number} [options.minProtocol]
|
|
29
|
+
* @param {number} [options.maxProtocol]
|
|
30
|
+
* @param {{baseMs?:number,maxMs?:number}} [options.reconnect]
|
|
31
|
+
* @param {boolean} [options.autoReconnect=true] 事件流断开后是否自动重连
|
|
32
|
+
*/
|
|
33
|
+
export function createAdapterClient(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
url,
|
|
36
|
+
token,
|
|
37
|
+
adapterToken,
|
|
38
|
+
fetchImpl = globalThis.fetch,
|
|
39
|
+
timeoutMs = 8000,
|
|
40
|
+
prefix = '/fang',
|
|
41
|
+
minProtocol,
|
|
42
|
+
maxProtocol,
|
|
43
|
+
reconnect = {},
|
|
44
|
+
autoReconnect = true,
|
|
45
|
+
} = options
|
|
46
|
+
|
|
47
|
+
const remote = createRemote({ url, token, adapterToken, fetchImpl, timeoutMs, prefix })
|
|
48
|
+
const impl = typeof fetchImpl === 'function' ? fetchImpl : null
|
|
49
|
+
const limits = {
|
|
50
|
+
...(Number.isInteger(minProtocol) ? { min: minProtocol } : {}),
|
|
51
|
+
...(Number.isInteger(maxProtocol) ? { max: maxProtocol } : {}),
|
|
52
|
+
}
|
|
53
|
+
const backoff = {
|
|
54
|
+
baseMs: positiveMs(reconnect?.baseMs, RECONNECT_DEFAULTS.baseMs),
|
|
55
|
+
maxMs: positiveMs(reconnect?.maxMs, RECONNECT_DEFAULTS.maxMs),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const listeners = {
|
|
59
|
+
event: new Set(),
|
|
60
|
+
state: new Set(),
|
|
61
|
+
reconnect: new Set(),
|
|
62
|
+
disconnect: new Set(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let mode = 'idle'
|
|
66
|
+
let protocol = 0
|
|
67
|
+
let sessionId = ''
|
|
68
|
+
let lastCursor = '0'
|
|
69
|
+
let lastState = null
|
|
70
|
+
let lastVersionInfo = null
|
|
71
|
+
let sessionMismatch = null
|
|
72
|
+
let protocolRange = { min: undefined, max: undefined }
|
|
73
|
+
let lastError = ''
|
|
74
|
+
let stopped = true
|
|
75
|
+
let controller = null
|
|
76
|
+
let pumpPromise = null
|
|
77
|
+
|
|
78
|
+
const emit = (kind, payload) => {
|
|
79
|
+
for (const fn of listeners[kind]) {
|
|
80
|
+
try {
|
|
81
|
+
fn(payload)
|
|
82
|
+
} catch {
|
|
83
|
+
/* 订阅者异常不得打断连接 */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const on = (kind) => (fn) => {
|
|
89
|
+
if (typeof fn !== 'function') return () => {}
|
|
90
|
+
listeners[kind].add(fn)
|
|
91
|
+
return () => listeners[kind].delete(fn)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pendingWaits = new Map()
|
|
95
|
+
|
|
96
|
+
const wait = (ms) => new Promise((resolve) => {
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
pendingWaits.delete(timer)
|
|
99
|
+
resolve()
|
|
100
|
+
}, ms)
|
|
101
|
+
pendingWaits.set(timer, resolve)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const backoffFor = (attempt) => Math.min(backoff.maxMs, backoff.baseMs * 2 ** Math.min(attempt, 5))
|
|
105
|
+
|
|
106
|
+
const headers = () => remote.authHeaders()
|
|
107
|
+
|
|
108
|
+
/** 用快照把 cursor/session 对齐(410 cursor-expired / 409 session-changed 后调用)。 */
|
|
109
|
+
async function resyncFromState(reason) {
|
|
110
|
+
lastCursor = '0'
|
|
111
|
+
const snapshot = await remote.state()
|
|
112
|
+
if (snapshot.ok && snapshot.data) {
|
|
113
|
+
lastState = snapshot.data
|
|
114
|
+
if (snapshot.data.sessionId) sessionId = String(snapshot.data.sessionId)
|
|
115
|
+
lastCursor = String(snapshot.data.lastCursor ?? '0')
|
|
116
|
+
emit('state', lastState)
|
|
117
|
+
}
|
|
118
|
+
emit('reconnect', { resetCursor: true, reason, sessionId, lastCursor })
|
|
119
|
+
return snapshot
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 打开一次 SSE 连接:返回可判定结果,不抛。 */
|
|
123
|
+
async function openOnce() {
|
|
124
|
+
if (!impl) return { ok: false, code: 'no-fetch', fatal: true }
|
|
125
|
+
controller = new AbortController()
|
|
126
|
+
const target = remote.eventsUrl({ since: lastCursor, session: sessionId })
|
|
127
|
+
let res = null
|
|
128
|
+
try {
|
|
129
|
+
res = await impl(target, { method: 'GET', headers: headers(), signal: controller.signal })
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, code: classifyRemoteFailure(e).kind, error: msgOf(e), retryable: true }
|
|
132
|
+
}
|
|
133
|
+
const status = Number(res?.status)
|
|
134
|
+
if (status === 401) return { ok: false, code: 'unauthorized', status, fatal: true }
|
|
135
|
+
if (status === 409) {
|
|
136
|
+
await resyncFromState('session-changed')
|
|
137
|
+
return { ok: false, code: 'session-changed', status, resync: true, retryable: true }
|
|
138
|
+
}
|
|
139
|
+
if (status === 410) {
|
|
140
|
+
await resyncFromState('cursor-expired')
|
|
141
|
+
return { ok: false, code: 'cursor-expired', status, resync: true, retryable: true }
|
|
142
|
+
}
|
|
143
|
+
if (!(status >= 200 && status < 300)) return { ok: false, code: `http-${status}`, status, retryable: true }
|
|
144
|
+
return { ok: true, res }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 读 SSE 流直到结束;解析出的每条事件更新 cursor 并回调 onEvent。 */
|
|
148
|
+
async function pump(res) {
|
|
149
|
+
const parser = createSseParser()
|
|
150
|
+
const body = res?.body
|
|
151
|
+
const onFrame = (frame) => {
|
|
152
|
+
if (!frame || !frame.data) return
|
|
153
|
+
let envelope = null
|
|
154
|
+
try {
|
|
155
|
+
envelope = JSON.parse(frame.data)
|
|
156
|
+
} catch {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const cursor = frame.id || (envelope && envelope.cursor) || ''
|
|
160
|
+
if (cursor) lastCursor = String(cursor)
|
|
161
|
+
if (envelope && envelope.event) emit('event', envelope)
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
if (body && typeof body.getReader === 'function') {
|
|
165
|
+
const reader = body.getReader()
|
|
166
|
+
const decoder = new TextDecoder()
|
|
167
|
+
for (;;) {
|
|
168
|
+
const chunk = await reader.read()
|
|
169
|
+
if (chunk.done) return { ok: true }
|
|
170
|
+
if (chunk.value !== undefined) {
|
|
171
|
+
for (const frame of parser.push(decoder.decode(chunk.value, { stream: true }))) onFrame(frame)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
|
176
|
+
const decoder = new TextDecoder()
|
|
177
|
+
for await (const value of body) {
|
|
178
|
+
for (const frame of parser.push(decoder.decode(value, { stream: true }))) onFrame(frame)
|
|
179
|
+
}
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}
|
|
182
|
+
return { ok: false, code: 'no-stream-body' }
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { ok: false, code: classifyRemoteFailure(e).kind, error: msgOf(e) }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 断线后的重连循环:指数退避;401 致命;409/410 先快照对齐再立即重试。 */
|
|
189
|
+
async function reconnectLoop() {
|
|
190
|
+
let attempt = 0
|
|
191
|
+
while (!stopped) {
|
|
192
|
+
const opened = await openOnce()
|
|
193
|
+
if (stopped) return
|
|
194
|
+
if (opened.fatal) {
|
|
195
|
+
mode = 'failed'
|
|
196
|
+
lastError = opened.code
|
|
197
|
+
emit('disconnect', { reason: opened.code, fatal: true })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (opened.ok) {
|
|
201
|
+
attempt = 0
|
|
202
|
+
mode = 'streaming'
|
|
203
|
+
emit('reconnect', { resetCursor: false, reason: 'resumed', sessionId, lastCursor })
|
|
204
|
+
const result = await pump(opened.res)
|
|
205
|
+
if (stopped) return
|
|
206
|
+
mode = 'reconnecting'
|
|
207
|
+
emit('disconnect', { reason: result.code ?? 'stream-closed' })
|
|
208
|
+
if (!autoReconnect) {
|
|
209
|
+
mode = 'idle'
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
await wait(backoffFor(attempt++))
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
mode = 'reconnecting'
|
|
216
|
+
lastError = opened.code
|
|
217
|
+
emit('disconnect', { reason: opened.code, retryable: true })
|
|
218
|
+
if (opened.resync) {
|
|
219
|
+
attempt = 0
|
|
220
|
+
await wait(backoffFor(0))
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
await wait(backoffFor(attempt++))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 建立会话:版本协商 → 首帧快照 → 打开事件流。
|
|
229
|
+
* 结果可判定:`{ok:true, protocol, sessionId, lastCursor}` 或 `{ok:false, code, fatal|retryable}`。
|
|
230
|
+
* 首连失败但可重试时仍会启动后台重连(设计上母体不放弃分身,只等在 onDisconnect 里上报)。
|
|
231
|
+
*/
|
|
232
|
+
async function connect() {
|
|
233
|
+
stopped = false
|
|
234
|
+
mode = 'negotiating'
|
|
235
|
+
const versionRes = await remote.version()
|
|
236
|
+
if (!versionRes.ok) {
|
|
237
|
+
const fatal = Number(versionRes.status) === 401
|
|
238
|
+
mode = fatal ? 'failed' : 'reconnecting'
|
|
239
|
+
lastError = versionRes.error ?? versionRes.kind ?? 'version-failed'
|
|
240
|
+
emit('disconnect', { reason: lastError, fatal })
|
|
241
|
+
return { ok: false, code: lastError, status: versionRes.status ?? 0, fatal }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const negotiated = negotiateProtocol(versionRes.data?.protocol, limits)
|
|
245
|
+
if (!negotiated.ok) {
|
|
246
|
+
mode = 'failed'
|
|
247
|
+
lastError = negotiated.reason
|
|
248
|
+
emit('disconnect', { reason: negotiated.reason, fatal: true })
|
|
249
|
+
return { ok: false, code: negotiated.reason, protocol: versionRes.data?.protocol }
|
|
250
|
+
}
|
|
251
|
+
protocol = negotiated.protocol
|
|
252
|
+
protocolRange = { min: negotiated.min, max: negotiated.max }
|
|
253
|
+
lastVersionInfo = versionRes.data && typeof versionRes.data === 'object' ? versionRes.data : { protocol: versionRes.data ?? null }
|
|
254
|
+
sessionId = String(versionRes.data?.sessionId ?? '')
|
|
255
|
+
|
|
256
|
+
const snapshot = await remote.state()
|
|
257
|
+
if (!snapshot.ok) {
|
|
258
|
+
const fatal = Number(snapshot.status) === 401
|
|
259
|
+
mode = fatal ? 'failed' : 'reconnecting'
|
|
260
|
+
lastError = snapshot.error ?? snapshot.kind ?? 'state-failed'
|
|
261
|
+
emit('disconnect', { reason: lastError, fatal })
|
|
262
|
+
return { ok: false, code: lastError, status: snapshot.status ?? 0, fatal }
|
|
263
|
+
}
|
|
264
|
+
lastState = snapshot.data ?? null
|
|
265
|
+
if (lastState?.sessionId) sessionId = String(lastState.sessionId)
|
|
266
|
+
if (lastState?.lastCursor !== undefined) lastCursor = String(lastState.lastCursor)
|
|
267
|
+
// 一致性诊断:version 与 state 之间 Adapter 若重启,sessionId 会变化。
|
|
268
|
+
// 此时 state 是最新真相,继续用 state.sessionId 连接;只记录差异供排障(不重试三步)。
|
|
269
|
+
const versionSession = String(lastVersionInfo?.sessionId ?? '')
|
|
270
|
+
const stateSession = String(lastState?.sessionId ?? '')
|
|
271
|
+
sessionMismatch = versionSession && stateSession && versionSession !== stateSession
|
|
272
|
+
? { versionSession, stateSession, at: Date.now() }
|
|
273
|
+
: null
|
|
274
|
+
emit('state', lastState)
|
|
275
|
+
|
|
276
|
+
const opened = await openOnce()
|
|
277
|
+
if (stopped) return { ok: false, code: 'stopped' }
|
|
278
|
+
if (!opened.ok) {
|
|
279
|
+
if (opened.fatal) {
|
|
280
|
+
mode = 'failed'
|
|
281
|
+
lastError = opened.code
|
|
282
|
+
emit('disconnect', { reason: opened.code, fatal: true })
|
|
283
|
+
return { ok: false, code: opened.code, fatal: true }
|
|
284
|
+
}
|
|
285
|
+
mode = 'reconnecting'
|
|
286
|
+
if (autoReconnect) void reconnectLoop()
|
|
287
|
+
return { ok: false, code: opened.code, retryable: true, sessionId, lastCursor }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
mode = 'streaming'
|
|
291
|
+
pumpPromise = pump(opened.res).then((result) => {
|
|
292
|
+
if (stopped) return result
|
|
293
|
+
mode = autoReconnect ? 'reconnecting' : 'idle'
|
|
294
|
+
emit('disconnect', { reason: result.code ?? 'stream-closed' })
|
|
295
|
+
if (autoReconnect) void reconnectLoop()
|
|
296
|
+
return result
|
|
297
|
+
})
|
|
298
|
+
return { ok: true, protocol, sessionId, state: lastState, lastCursor, version: lastVersionInfo, sessionMismatch }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 停止事件流(幂等)。未到期的退避计时器会被立即唤醒,让重连循环看到 stopped 退出。 */
|
|
302
|
+
function stop() {
|
|
303
|
+
stopped = true
|
|
304
|
+
try {
|
|
305
|
+
controller?.abort()
|
|
306
|
+
} catch {
|
|
307
|
+
/* 老运行时没有 abort 也不影响逻辑 */
|
|
308
|
+
}
|
|
309
|
+
controller = null
|
|
310
|
+
for (const [timer, resolve] of pendingWaits) {
|
|
311
|
+
clearTimeout(timer)
|
|
312
|
+
resolve()
|
|
313
|
+
}
|
|
314
|
+
pendingWaits.clear()
|
|
315
|
+
mode = 'idle'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 幂等命令:生成 commandId,网络/超时失败时用**同一个** commandId 重试一次。
|
|
320
|
+
* 服务端命中 CommandCache 会返回 `deduplicated:true`,不会重复执行副作用。
|
|
321
|
+
*/
|
|
322
|
+
async function command(op, payload = {}, { commandId = newCommandId(), async: isAsync = false, retries = 1 } = {}) {
|
|
323
|
+
const envelope = { commandId, op: String(op ?? ''), payload: payload ?? {} }
|
|
324
|
+
if (isAsync) envelope.async = true
|
|
325
|
+
let result = await remote.commands(envelope)
|
|
326
|
+
let attempt = 0
|
|
327
|
+
while (!result.ok && attempt < retries && (result.kind === 'network' || result.kind === 'timeout')) {
|
|
328
|
+
await wait(backoffFor(attempt++))
|
|
329
|
+
result = await remote.commands(envelope)
|
|
330
|
+
}
|
|
331
|
+
return result
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
...remote,
|
|
336
|
+
connect,
|
|
337
|
+
stop,
|
|
338
|
+
command,
|
|
339
|
+
onEvent: on('event'),
|
|
340
|
+
onState: on('state'),
|
|
341
|
+
onReconnect: on('reconnect'),
|
|
342
|
+
onDisconnect: on('disconnect'),
|
|
343
|
+
get mode() {
|
|
344
|
+
return mode
|
|
345
|
+
},
|
|
346
|
+
get protocol() {
|
|
347
|
+
return protocol
|
|
348
|
+
},
|
|
349
|
+
get sessionId() {
|
|
350
|
+
return sessionId
|
|
351
|
+
},
|
|
352
|
+
get lastCursor() {
|
|
353
|
+
return lastCursor
|
|
354
|
+
},
|
|
355
|
+
get lastState() {
|
|
356
|
+
return lastState
|
|
357
|
+
},
|
|
358
|
+
/** 最近一次 /adapter/version 的原始响应(protocol / version / sessionId…),供诊断。 */
|
|
359
|
+
get lastVersionInfo() {
|
|
360
|
+
return lastVersionInfo
|
|
361
|
+
},
|
|
362
|
+
/** version 与 state 的 sessionId 不一致时记录(Adapter 在两次请求之间重启过)。 */
|
|
363
|
+
get sessionMismatch() {
|
|
364
|
+
return sessionMismatch
|
|
365
|
+
},
|
|
366
|
+
/** 本客户端实际用于协商的协议区间(min/max),供诊断与上层审计。 */
|
|
367
|
+
get protocolRange() {
|
|
368
|
+
return { ...protocolRange }
|
|
369
|
+
},
|
|
370
|
+
get lastError() {
|
|
371
|
+
return lastError
|
|
372
|
+
},
|
|
373
|
+
get streaming() {
|
|
374
|
+
return mode === 'streaming'
|
|
375
|
+
},
|
|
376
|
+
/** 等待首帧事件流结束(测试/编排器优雅停止时用)。 */
|
|
377
|
+
get pumpDone() {
|
|
378
|
+
return pumpPromise ?? Promise.resolve(null)
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const _internals = { positiveMs }
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang v2.6.1 — Adapter 协议的可共享部分(契约:docs/PROTOCOL-V3.md)。
|
|
4
|
+
//
|
|
5
|
+
// 这里只放**纯函数与纯状态**:cursor 编解码、事件环形缓冲、命令幂等缓存、
|
|
6
|
+
// 协议协商、Bearer 解析、SSE 帧编解码、错误码 → HTTP 状态码。
|
|
7
|
+
// 不 import `node:*`,因此分身侧(adapter.mjs)与母体侧(adapter-client.mjs)
|
|
8
|
+
// 可以共用同一份语义,测试也不需要真实 socket。
|
|
9
|
+
import {
|
|
10
|
+
ADAPTER_ERROR_STATUS,
|
|
11
|
+
ADAPTER_PROTOCOL_MIN,
|
|
12
|
+
ADAPTER_PROTOCOL_VERSION,
|
|
13
|
+
} from '../kernel/types.mjs'
|
|
14
|
+
|
|
15
|
+
/** 事件环形缓冲默认容量:母体断线后能追多久(1000 条 ≈ 数十分钟的常规事件)。 */
|
|
16
|
+
export const DEFAULT_EVENT_CAPACITY = 1000
|
|
17
|
+
/** 命令幂等缓存默认容量(LRU)。 */
|
|
18
|
+
export const DEFAULT_COMMAND_CACHE_SIZE = 500
|
|
19
|
+
/** SSE 心跳间隔:注释帧,不占 cursor(应小于常见代理 60s idle 超时)。 */
|
|
20
|
+
export const SSE_HEARTBEAT_MS = 15000
|
|
21
|
+
/** 母体侧重连退避默认值(毫秒)。 */
|
|
22
|
+
export const RECONNECT_DEFAULTS = Object.freeze({ baseMs: 500, maxMs: 8000 })
|
|
23
|
+
|
|
24
|
+
const MAX_UINT64 = 18446744073709551615n
|
|
25
|
+
const DECIMAL_RE = /^(0|[1-9][0-9]{0,19})$/
|
|
26
|
+
const COMMAND_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* cursor 是单调递增 uint64,JSON 里用十进制字符串表示(bigint 不能 JSON 序列化)。
|
|
30
|
+
* @param {unknown} value
|
|
31
|
+
* @returns {bigint|null} 非法输入返回 null(调用方映射 400 cursor-invalid)
|
|
32
|
+
*/
|
|
33
|
+
export function parseCursor(value) {
|
|
34
|
+
if (typeof value === 'bigint') return value >= 0n && value <= MAX_UINT64 ? value : null
|
|
35
|
+
if (typeof value === 'number') {
|
|
36
|
+
if (!Number.isSafeInteger(value) || value < 0) return null
|
|
37
|
+
return BigInt(value)
|
|
38
|
+
}
|
|
39
|
+
if (typeof value !== 'string') return null
|
|
40
|
+
const text = value.trim()
|
|
41
|
+
if (!DECIMAL_RE.test(text)) return null
|
|
42
|
+
try {
|
|
43
|
+
const n = BigInt(text)
|
|
44
|
+
return n <= MAX_UINT64 ? n : null
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {bigint|number|string} value @returns {string} */
|
|
51
|
+
export function formatCursor(value) {
|
|
52
|
+
try {
|
|
53
|
+
const n = typeof value === 'bigint' ? value : BigInt(value)
|
|
54
|
+
return n < 0n ? '0' : n.toString()
|
|
55
|
+
} catch {
|
|
56
|
+
return '0'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 分身侧事件环形缓冲。append 返回完整事件信封;since 支持断线续传。
|
|
62
|
+
*
|
|
63
|
+
* cursor 语义(PROTOCOL-V3 §7):
|
|
64
|
+
* * `since=0` → 从头回放当前缓冲;
|
|
65
|
+
* * `since` 落在缓冲内 → 返回之后的事件,gap=false;
|
|
66
|
+
* * `since` 早于最旧一条 → gap=true(调用方回 410,让母体重新拉快照);
|
|
67
|
+
* * `since` 超前(分身重启 / 伪造)→ gap=true,reason='cursor-ahead'。
|
|
68
|
+
*/
|
|
69
|
+
export class EventLog {
|
|
70
|
+
constructor({ capacity = DEFAULT_EVENT_CAPACITY, sessionId = '', now = () => Date.now() } = {}) {
|
|
71
|
+
const size = Math.floor(Number(capacity))
|
|
72
|
+
this.capacity = Number.isFinite(size) && size > 0 ? size : DEFAULT_EVENT_CAPACITY
|
|
73
|
+
this.sessionId = String(sessionId ?? '')
|
|
74
|
+
this.entries = []
|
|
75
|
+
this.next = 1n
|
|
76
|
+
this.now = typeof now === 'function' ? now : () => Date.now()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get size() {
|
|
80
|
+
return this.entries.length
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 最后一条事件的 cursor(空缓冲返回 '0')。 */
|
|
84
|
+
get lastCursor() {
|
|
85
|
+
return this.entries.length > 0 ? this.entries[this.entries.length - 1].cursor : '0'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 下一条事件将拿到的 cursor(不会回退)。 */
|
|
89
|
+
get nextCursor() {
|
|
90
|
+
return formatCursor(this.next)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 追加一条事件。
|
|
95
|
+
* @param {object} event
|
|
96
|
+
* @returns {{cursor:string, sessionId:string, at:number, event:object}}
|
|
97
|
+
*/
|
|
98
|
+
append(event) {
|
|
99
|
+
const cursor = this.next++
|
|
100
|
+
const body = event && typeof event === 'object' ? event : { kind: 'error', code: 'bad-event' }
|
|
101
|
+
const envelope = {
|
|
102
|
+
cursor: formatCursor(cursor),
|
|
103
|
+
sessionId: this.sessionId,
|
|
104
|
+
at: this.now(),
|
|
105
|
+
event: body,
|
|
106
|
+
}
|
|
107
|
+
this.entries.push(envelope)
|
|
108
|
+
if (this.entries.length > this.capacity) {
|
|
109
|
+
this.entries.splice(0, this.entries.length - this.capacity)
|
|
110
|
+
}
|
|
111
|
+
return envelope
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 取 `cursor` 之后的事件。
|
|
116
|
+
* @param {unknown} cursor
|
|
117
|
+
* @returns {{ok:boolean, code?:string, events:object[], gap:boolean, reason?:string,
|
|
118
|
+
* lastCursor:string, nextCursor:string, sessionId:string}}
|
|
119
|
+
*/
|
|
120
|
+
since(cursor) {
|
|
121
|
+
const parsed = parseCursor(cursor)
|
|
122
|
+
if (parsed === null) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
code: 'cursor-invalid',
|
|
126
|
+
events: [],
|
|
127
|
+
gap: false,
|
|
128
|
+
lastCursor: this.lastCursor,
|
|
129
|
+
nextCursor: this.nextCursor,
|
|
130
|
+
sessionId: this.sessionId,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const base = { ok: true, events: [], gap: false, lastCursor: this.lastCursor, nextCursor: this.nextCursor, sessionId: this.sessionId }
|
|
134
|
+
if (this.entries.length === 0) return base
|
|
135
|
+
if (parsed >= this.next) return { ...base, gap: true, reason: 'cursor-ahead' }
|
|
136
|
+
const oldest = parseCursor(this.entries[0].cursor)
|
|
137
|
+
const events = this.entries.filter((entry) => parseCursor(entry.cursor) > parsed)
|
|
138
|
+
return { ...base, events, gap: oldest !== null && parsed + 1n < oldest }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
clear() {
|
|
142
|
+
this.entries = []
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 命令幂等缓存已独立到 `command-cache.mjs`(v2.6.3 起:in-flight 合并 + 失败契约);
|
|
147
|
+
// 这里保留 re-export,避免 Adapter 与旧测试的 import 路径断裂。
|
|
148
|
+
export { CommandCache, COMMAND_CACHE_DEFAULT_CAPACITY } from './command-cache.mjs'
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 版本协商。父体拿到分身 /adapter/version 的 protocol 后调用。
|
|
152
|
+
* @param {unknown} remote
|
|
153
|
+
* @param {{min?:number, max?:number}} [options]
|
|
154
|
+
* @returns {{ok:boolean, protocol?:number, reason?:string, remote?:unknown, min:number, max:number}}
|
|
155
|
+
*/
|
|
156
|
+
export function negotiateProtocol(remote, { min = ADAPTER_PROTOCOL_MIN, max = ADAPTER_PROTOCOL_VERSION } = {}) {
|
|
157
|
+
const raw = Number(remote)
|
|
158
|
+
if (!Number.isInteger(raw) || raw < 1) {
|
|
159
|
+
return { ok: false, reason: 'protocol-invalid', remote, min, max }
|
|
160
|
+
}
|
|
161
|
+
if (raw < min) return { ok: false, reason: 'protocol-too-old', protocol: raw, remote: raw, min, max }
|
|
162
|
+
if (raw > max) return { ok: false, reason: 'protocol-too-new', protocol: raw, remote: raw, min, max }
|
|
163
|
+
return { ok: true, protocol: raw, min, max }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 从 Authorization 头取 Bearer token(大小写不敏感,空值返回 '')。 */
|
|
167
|
+
export function parseBearer(header) {
|
|
168
|
+
const text = String(header ?? '').trim()
|
|
169
|
+
if (!text) return ''
|
|
170
|
+
const match = /^Bearer\s+(\S+)$/i.exec(text)
|
|
171
|
+
return match ? match[1] : ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 校验并归一化 v3 命令信封。
|
|
176
|
+
* 接受 `{commandId, op, payload, async}`,也接受嵌套 `{commandId, command:{kind, payload}}`。
|
|
177
|
+
* @returns {{ok:true, commandId:string, op:string, payload:object, async:boolean} |
|
|
178
|
+
* {ok:false, code:'command-id-invalid'|'missing-op', error:string, commandId:string}}
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeCommandEnvelope(input) {
|
|
181
|
+
const raw = input && typeof input === 'object' ? input : {}
|
|
182
|
+
const command = raw.command && typeof raw.command === 'object' ? raw.command : null
|
|
183
|
+
const commandId = raw.commandId === undefined || raw.commandId === null ? '' : String(raw.commandId).trim()
|
|
184
|
+
if (commandId && !COMMAND_ID_RE.test(commandId)) {
|
|
185
|
+
return { ok: false, code: 'command-id-invalid', error: 'command-id-invalid', commandId }
|
|
186
|
+
}
|
|
187
|
+
const op = String(raw.op ?? command?.op ?? command?.kind ?? '').trim()
|
|
188
|
+
if (!op) return { ok: false, code: 'missing-op', error: 'missing-op', commandId }
|
|
189
|
+
const payloadRaw = raw.payload ?? command?.payload
|
|
190
|
+
const payload = payloadRaw && typeof payloadRaw === 'object' ? payloadRaw : {}
|
|
191
|
+
const async = raw.async === true || command?.async === true || payload.async === true
|
|
192
|
+
return { ok: true, commandId, op, payload, async }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 构造失败信封(所有 Adapter 端点共用)。 */
|
|
196
|
+
export function adapterError(code, message, extra = null) {
|
|
197
|
+
const envelope = { ok: false, code: String(code ?? 'internal-error'), error: String(message ?? code ?? 'internal-error') }
|
|
198
|
+
if (extra && typeof extra === 'object') Object.assign(envelope, extra)
|
|
199
|
+
return envelope
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 错误码 → HTTP 状态码(未登记 = 500)。 */
|
|
203
|
+
export function statusForAdapterError(code) {
|
|
204
|
+
return ADAPTER_ERROR_STATUS[String(code ?? '')] ?? 500
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 一条 SSE 帧。data 是事件信封的 JSON(JSON 会转义换行,因此永远是单行)。
|
|
209
|
+
* @param {{cursor:string, sessionId:string, at:number, event:object}} envelope
|
|
210
|
+
* @param {string} [eventName]
|
|
211
|
+
*/
|
|
212
|
+
export function sseFrame(envelope, eventName = 'adapter') {
|
|
213
|
+
const data = JSON.stringify(envelope)
|
|
214
|
+
return `id: ${envelope.cursor}\nevent: ${eventName}\ndata: ${data}\n\n`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** SSE 注释帧(心跳/连接确认),不占 cursor。 */
|
|
218
|
+
export function sseComment(text = 'heartbeat') {
|
|
219
|
+
return `: ${text}\n\n`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 连接建立时建议客户端重连间隔。 */
|
|
223
|
+
export const SSE_RETRY_FRAME = 'retry: 2000\n\n'
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 增量 SSE 解析器(母体侧):把任意 chunk 切片还原成完整帧。
|
|
227
|
+
* v2.6.5 起独立成 `sse-parser.mjs` 的 `SseParser` 类(便于模糊测试);
|
|
228
|
+
* `flush()` 按 SSE 规范丢弃未完成的残帧。
|
|
229
|
+
* @returns {{push:(chunk:unknown)=>object[], flush:()=>object[]}}
|
|
230
|
+
*/
|
|
231
|
+
export { SseParser, createSseParser } from './sse-parser.mjs'
|
|
232
|
+
|
|
233
|
+
/** 生成一个命令幂等键(crypto.randomUUID 可用时优先)。 */
|
|
234
|
+
export function newCommandId() {
|
|
235
|
+
try {
|
|
236
|
+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
|
237
|
+
return `cmd_${globalThis.crypto.randomUUID()}`
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
/* 老运行时退化到时间戳 + 随机串 */
|
|
241
|
+
}
|
|
242
|
+
return `cmd_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
243
|
+
}
|