@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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ /**
5
+ * 回流(relay)接口 —— contract 1.1 新增能力。
6
+ *
7
+ * 背景:房主(dsh-Master)的成本总线产生 `cost:event` / `cost:degraded` /
8
+ * `cost:substitution` 三类事件,需要**单向**投递给瓮城(dsh-Fang)做产物回流与
9
+ * 信息栏展示。1.0 的方法表只有请求-响应式的 fork/decision/session,缺一个批量、
10
+ * 可去重、有回执的事件入口,因此 1.1 追加两个方法:
11
+ *
12
+ * * `fang.relay.publish` —— 批量投递事件,按 `event.id` 幂等去重,返回回执与游标;
13
+ * * `fang.relay.status` —— 对端自报回流状态(自身标识 peerLabel、游标、累计计数)。
14
+ *
15
+ * 兼容规则(README §2):新增方法只升 MINOR;1.0 的对端在 `hello` 协商时不会
16
+ * 宣告这两个方法,方法集被自动裁剪,调用方据此判定“回流不可用”,**不得**把
17
+ * 裁剪当成失败重试。
18
+ *
19
+ * 本文件只做协议与参考实现:不读写磁盘、不联网、不持有任何凭据。
20
+ */
21
+
22
+ import { CONTRACT_VERSION, METHODS, RELAY_LIMITS } from './constants.js'
23
+ import { createContractServer } from './server.js'
24
+
25
+ function nowMs(now) {
26
+ const value = typeof now === 'function' ? now() : Date.now()
27
+ const n = Number(value)
28
+ return Number.isFinite(n) ? n : Date.now()
29
+ }
30
+
31
+ function asLimit(value, fallback) {
32
+ const n = Number(value)
33
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
34
+ }
35
+
36
+ function byteLength(value) {
37
+ try { return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8') } catch { return Number.POSITIVE_INFINITY }
38
+ }
39
+
40
+ /**
41
+ * 校验一批回流事件。逐条判定:合法的接受,非法的计 `rejected` 并给原因,
42
+ * 不因单条坏数据丢掉整批(但整批不可用会抛 `relay-events-invalid`)。
43
+ *
44
+ * @param {unknown} events
45
+ * @param {{maxBatch?:number, maxEventBytes?:number, maxPayloadBytes?:number}} [limits]
46
+ */
47
+ export function validateRelayEvents(events, limits = {}) {
48
+ const maxBatch = asLimit(limits.maxBatch, RELAY_LIMITS.maxBatch)
49
+ const maxEventBytes = asLimit(limits.maxEventBytes, RELAY_LIMITS.maxEventBytes)
50
+ const maxPayloadBytes = asLimit(limits.maxPayloadBytes, RELAY_LIMITS.maxPayloadBytes)
51
+ const errors = []
52
+ if (!Array.isArray(events)) {
53
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-must-be-array'], accepted: [], rejected: [] }
54
+ }
55
+ if (events.length === 0) {
56
+ return { ok: false, code: 'relay-events-invalid', errors: ['events-empty'], accepted: [], rejected: [] }
57
+ }
58
+ if (events.length > maxBatch) {
59
+ return { ok: false, code: 'relay-events-invalid', errors: [`events-too-many(>${maxBatch})`], accepted: [], rejected: [] }
60
+ }
61
+ if (byteLength(events) > maxPayloadBytes) {
62
+ return { ok: false, code: 'relay-events-invalid', errors: [`payload-too-large(>${maxPayloadBytes})`], accepted: [], rejected: [] }
63
+ }
64
+ const accepted = []
65
+ const rejected = []
66
+ const seen = new Set()
67
+ for (const raw of events) {
68
+ const item = raw && typeof raw === 'object' ? raw : null
69
+ if (!item) { rejected.push({ id: null, reason: 'event-not-object' }); continue }
70
+ const id = typeof item.id === 'string' ? item.id.trim() : ''
71
+ if (!id) { rejected.push({ id: null, reason: 'id-required' }); continue }
72
+ if (typeof item.type !== 'string' || item.type.trim() === '') { rejected.push({ id, reason: 'type-required' }); continue }
73
+ if (byteLength(item) > maxEventBytes) { rejected.push({ id, reason: `event-too-large(>${maxEventBytes})` }); continue }
74
+ // 同一批内重复 id 也算重复投递:只接受第一次,其余计 duplicates。
75
+ if (seen.has(id)) { rejected.push({ id, reason: 'duplicate-in-batch' }); continue }
76
+ seen.add(id)
77
+ accepted.push({
78
+ id,
79
+ type: String(item.type),
80
+ at: Number.isFinite(Number(item.at)) ? Number(item.at) : 0,
81
+ payload: item.payload && typeof item.payload === 'object' ? item.payload : {},
82
+ meta: item.meta && typeof item.meta === 'object' ? item.meta : {},
83
+ })
84
+ }
85
+ return { ok: accepted.length > 0 || rejected.length === 0, errors, accepted, rejected }
86
+ }
87
+
88
+ /**
89
+ * 回流接收端(瓮城侧)的参考存储:按 event.id 幂等去重,保留有界回执环。
90
+ *
91
+ * @param {{
92
+ * peerLabel?: string, role?: string, instanceId?: string,
93
+ * maxRecords?: number, now?: () => number, limits?: object,
94
+ * }} [options]
95
+ */
96
+ export function createRelayStore(options = {}) {
97
+ const peerLabel = String(options.peerLabel ?? 'contract-server')
98
+ const role = String(options.role ?? 'mother')
99
+ const instanceId = String(options.instanceId ?? '')
100
+ const maxRecords = asLimit(options.maxRecords, RELAY_LIMITS.maxRecords)
101
+ const limits = { ...RELAY_LIMITS, ...(options.limits && typeof options.limits === 'object' ? options.limits : {}) }
102
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
103
+ const seen = new Map()
104
+ const receipts = []
105
+ const counters = { accepted: 0, duplicates: 0, rejected: 0, batches: 0 }
106
+ let cursor = 0
107
+ let lastAt = 0
108
+ const lastErrors = []
109
+
110
+ function remember(receipt) {
111
+ receipts.push(receipt)
112
+ if (receipts.length > maxRecords) receipts.shift()
113
+ }
114
+
115
+ /** @param {{events?: unknown, batchId?: string, taskId?: string}} [params] */
116
+ function publish(params = {}) {
117
+ const validated = validateRelayEvents(params?.events, limits)
118
+ if (validated.ok === false) {
119
+ const error = new Error(validated.errors.join(', ') || 'relay-events-invalid')
120
+ error.code = 'relay-events-invalid'
121
+ error.retryable = false
122
+ throw error
123
+ }
124
+ counters.batches += 1
125
+ const acceptedIds = []
126
+ let duplicates = 0
127
+ for (const event of validated.accepted) {
128
+ if (seen.has(event.id)) { duplicates += 1; continue }
129
+ seen.set(event.id, { at: nowMs(now), cursor: cursor + 1 })
130
+ cursor += 1
131
+ counters.accepted += 1
132
+ acceptedIds.push(event.id)
133
+ remember({ id: event.id, type: event.type, at: event.at, cursor })
134
+ lastAt = nowMs(now)
135
+ }
136
+ counters.duplicates += duplicates
137
+ for (const item of validated.rejected) {
138
+ // 批内重复计入 duplicates;其余非法条目计入 rejected 并留原因(有界)。
139
+ if (item.reason === 'duplicate-in-batch') { duplicates += 1; counters.duplicates += 1; continue }
140
+ counters.rejected += 1
141
+ if (lastErrors.length < 50) lastErrors.push({ id: item.id, reason: item.reason })
142
+ }
143
+ const batchId = String(params?.batchId ?? `batch-${counters.batches}`)
144
+ const receipt = Object.freeze({
145
+ ok: true,
146
+ batchId,
147
+ taskId: params?.taskId === undefined ? null : String(params.taskId),
148
+ accepted: acceptedIds.length,
149
+ duplicates,
150
+ rejected: validated.rejected.length - duplicates,
151
+ cursor,
152
+ acceptedIds,
153
+ peer: peerLabel,
154
+ at: nowMs(now),
155
+ })
156
+ if (seen.size > maxRecords * 4) {
157
+ // 幂等窗口有界:按插入顺序淘汰最老的一半,避免长跑进程无界增长。
158
+ const overflow = seen.size - maxRecords * 2
159
+ let removed = 0
160
+ for (const key of seen.keys()) {
161
+ if (removed >= overflow) break
162
+ seen.delete(key)
163
+ removed += 1
164
+ }
165
+ }
166
+ return receipt
167
+ }
168
+
169
+ function status() {
170
+ return Object.freeze({
171
+ ok: true,
172
+ available: true,
173
+ peer: peerLabel,
174
+ role,
175
+ instanceId,
176
+ contractVersion: CONTRACT_VERSION,
177
+ cursor,
178
+ accepted: counters.accepted,
179
+ duplicates: counters.duplicates,
180
+ rejected: counters.rejected,
181
+ batches: counters.batches,
182
+ lastAt,
183
+ limits: { ...limits },
184
+ lastErrors: [...lastErrors],
185
+ })
186
+ }
187
+
188
+ function describe() {
189
+ return Object.freeze({ peerLabel, records: receipts.length, seen: seen.size, ...counters, cursor })
190
+ }
191
+
192
+ return Object.freeze({ publish, status, describe, receipts: () => [...receipts] })
193
+ }
194
+
195
+ /**
196
+ * 把回流 store 包成契约 handler 表(挂到 `createContractServer({handlers})`)。
197
+ * @param {{publish:Function, status:Function}} store
198
+ */
199
+ export function relayHandlers(store) {
200
+ if (!store || typeof store.publish !== 'function' || typeof store.status !== 'function') {
201
+ throw new Error('relayHandlers requires a relay store with publish/status')
202
+ }
203
+ return {
204
+ [METHODS.RELAY_PUBLISH]: (params) => store.publish(params ?? {}),
205
+ [METHODS.RELAY_STATUS]: () => store.status(),
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 便捷构造:带回流能力的契约服务端(瓮城侧挂载用)。
211
+ * `peerLabel` 是**自报**标识,房主侧据此记录“对端是谁”,不参与鉴权。
212
+ */
213
+ export function createRelayContractServer(options = {}) {
214
+ const { peerLabel = 'contract-server', role = 'mother', instanceId = '', now, limits, maxRecords, extraHandlers = {}, ...rest } = options
215
+ const store = createRelayStore({ peerLabel, role, instanceId, now, limits, maxRecords })
216
+ const server = createContractServer({
217
+ role,
218
+ instanceId,
219
+ handlers: { ...relayHandlers(store), ...(extraHandlers && typeof extraHandlers === 'object' ? extraHandlers : {}) },
220
+ ...rest,
221
+ })
222
+ return { server, store }
223
+ }
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ import { CAPABILITIES, CONTRACT_VERSION, ERROR_CODES, METHOD_VERSION, METHODS } from './constants.js'
5
+ import { createResponse, normalizeError, validateEnvelope } from './envelope.js'
6
+ import { helloPayload, parseVersion, unsupportedMethodError } from './negotiate.js'
7
+
8
+ /**
9
+ * 契约服务端(Fang 侧/任意一侧):
10
+ * * hello() 返回协商信息;
11
+ * * invoke(envelope) 校验信封、检查方法版本、调用 handler、返回标准响应。
12
+ *
13
+ * 兼容原则:新增方法只需加 handler;删除/改义必须同时保留 method@old 一个 MINOR 周期。
14
+ */
15
+ export function createContractServer({
16
+ role = 'mother',
17
+ instanceId = '',
18
+ version = CONTRACT_VERSION,
19
+ capabilities = CAPABILITIES,
20
+ transports = [],
21
+ handlers = {},
22
+ logger = null,
23
+ startedAt = Date.now(),
24
+ } = {}) {
25
+ const registry = new Map(Object.entries(handlers))
26
+ const hello = () => helloPayload({
27
+ role,
28
+ instanceId,
29
+ version,
30
+ methods: [...new Set([METHODS.HELLO, METHODS.HEALTH, ...registry.keys()])],
31
+ capabilities,
32
+ transports,
33
+ startedAt,
34
+ })
35
+ const logs = { invoked: 0, failed: 0, lastError: null }
36
+
37
+ const invoke = async (envelope) => {
38
+ const validated = validateEnvelope(envelope)
39
+ if (!validated.ok) {
40
+ return createResponse({ id: envelope?.id, error: { code: ERROR_CODES.INVALID_ENVELOPE, message: validated.errors.join(', ') } })
41
+ }
42
+ const method = String(envelope.method)
43
+ try {
44
+ if (method === METHODS.HELLO) return createResponse({ id: envelope.id, result: hello() })
45
+ if (method === METHODS.HEALTH) {
46
+ return createResponse({ id: envelope.id, result: { ok: true, role, instanceId, version, uptimeMs: Date.now() - startedAt, methods: [...registry.keys()] } })
47
+ }
48
+ const handler = registry.get(method)
49
+ if (!handler) return createResponse({ id: envelope.id, error: unsupportedMethodError(method) })
50
+ const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0')
51
+ const localVersion = parseVersion(version)
52
+ if (methodVersion && localVersion && methodVersion.major !== localVersion.major) {
53
+ return createResponse({ id: envelope.id, error: { code: ERROR_CODES.UNSUPPORTED_VERSION, message: `method ${method} requires contract major ${methodVersion.major}` } })
54
+ }
55
+ logs.invoked += 1
56
+ const result = await handler(envelope.params ?? null, envelope)
57
+ return createResponse({ id: envelope.id, result: result ?? null, meta: { role, instanceId } })
58
+ } catch (error) {
59
+ logs.failed += 1
60
+ logs.lastError = normalizeError(error, ERROR_CODES.HANDLER_THREW)
61
+ if (!error?.code) logs.lastError.code = ERROR_CODES.HANDLER_THREW
62
+ try { logger?.warn?.(`[contract] ${method} failed: ${logs.lastError.code} ${logs.lastError.message}`) } catch { /* ignore */ }
63
+ return createResponse({ id: envelope?.id, error: logs.lastError })
64
+ }
65
+ }
66
+
67
+ return {
68
+ hello,
69
+ invoke,
70
+ describe() {
71
+ return {
72
+ protocol: 'fang-master', contractVersion: version, role, instanceId,
73
+ methods: [...registry.keys()], capabilities, transports, stats: { ...logs },
74
+ }
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ import fs from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ import {
8
+ CONTRACT_VERSION,
9
+ DEFAULT_TRANSPORT_ORDER,
10
+ ERROR_CODES,
11
+ LEGACY_SERVICE_KEYS,
12
+ METHODS,
13
+ PROTOCOL,
14
+ } from './constants.js'
15
+ import { ContractTransportError, createEnvelope, createResponse, normalizeError, validateEnvelope } from './envelope.js'
16
+ import { helloPayload, negotiate } from './negotiate.js'
17
+
18
+ function safeGet(ctx, name) {
19
+ try {
20
+ if (!ctx || typeof ctx !== 'object') return null
21
+ if (typeof ctx.get === 'function') return ctx.get(name) ?? null
22
+ return ctx[name] ?? null
23
+ } catch { return null }
24
+ }
25
+
26
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms))) }
27
+
28
+ function authHeaders(token) {
29
+ return token ? { authorization: `Bearer ${token}` } : {}
30
+ }
31
+
32
+ function baseOf(url) { return String(url ?? '').trim().replace(/\/+$/, '') }
33
+
34
+ /** 统一传输接口:{name, kind, available(), hello(), invoke(method, params, opts)}。 */
35
+
36
+ export function createLocalTransport({ ctx, serviceName = 'fangContract' } = {}) {
37
+ const service = () => safeGet(ctx, serviceName)
38
+ return {
39
+ name: 'local', kind: 'local', available: () => Boolean(service() && typeof service().invoke === 'function'),
40
+ async hello() {
41
+ const s = service()
42
+ if (!s) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
43
+ return await s.hello()
44
+ },
45
+ async invoke(method, params, opts = {}) {
46
+ const s = service()
47
+ if (!s || typeof s.invoke !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
48
+ const response = await s.invoke(createEnvelope({ method, params, source: 'local', meta: opts.meta, timeoutMs: opts.timeoutMs }))
49
+ const check = validateEnvelope(response, { response: true })
50
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'local' })
51
+ return response
52
+ },
53
+ }
54
+ }
55
+
56
+ export function createHttpTransport({ baseUrl = '', token = '', headers: extraHeaders = {}, fetchImpl = fetch, timeoutMs = 15_000, pathPrefix = '/fang/api/contract' } = {}) {
57
+ const base = baseOf(baseUrl)
58
+ const prefix = `/${String(pathPrefix).replace(/^\/+|\/+$/g, '')}`
59
+ const available = () => Boolean(base)
60
+ const call = async (endpoint, role, fn, callTimeoutMs = timeoutMs) => {
61
+ if (!available()) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'http contract baseUrl is missing', { transport: 'http' })
62
+ const effectiveTimeout = Math.max(1, Number(callTimeoutMs) || timeoutMs)
63
+ const controller = new AbortController()
64
+ const timer = setTimeout(() => controller.abort(), effectiveTimeout)
65
+ timer.unref?.()
66
+ try {
67
+ const response = await fetchImpl(`${base}${prefix}${endpoint}`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...authHeaders(token), ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}) },
70
+ body: JSON.stringify(role),
71
+ signal: controller.signal,
72
+ })
73
+ const text = await response.text()
74
+ if (!response.ok) {
75
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, `HTTP ${response.status}`, { transport: 'http', detail: text.slice(0, 500) })
76
+ }
77
+ let parsed = null
78
+ try { parsed = JSON.parse(text) } catch { throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'HTTP response is not JSON', { transport: 'http' }) }
79
+ return fn(parsed)
80
+ } catch (error) {
81
+ if (error instanceof ContractTransportError) throw error
82
+ if (error?.name === 'AbortError') throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `HTTP contract timeout (>${timeoutMs}ms)`, { transport: 'http', retryable: true })
83
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, String(error?.message ?? error), { transport: 'http', retryable: true })
84
+ } finally { clearTimeout(timer) }
85
+ }
86
+ return {
87
+ name: 'http', kind: 'http', available,
88
+ async hello() {
89
+ return await call('/hello', { method: METHODS.HELLO }, (parsed) => {
90
+ // 允许 /hello 直接返回 payload,或返回标准 response envelope。
91
+ if (parsed && parsed.protocol) return parsed
92
+ if (parsed && parsed.ok && parsed.result) return parsed.result
93
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'invalid hello payload', { transport: 'http' })
94
+ })
95
+ },
96
+ async invoke(method, params, opts = {}) {
97
+ const envelope = createEnvelope({ method, params, source: 'http', meta: opts.meta, timeoutMs: opts.timeoutMs })
98
+ return await call('/invoke', envelope, (parsed) => {
99
+ const check = validateEnvelope(parsed, { response: true })
100
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'http' })
101
+ return parsed
102
+ }, opts.timeoutMs)
103
+ },
104
+ }
105
+ }
106
+
107
+ function atomicWrite(file, text) {
108
+ const tmp = `${file}.${process.pid}.tmp`
109
+ return fs.writeFile(tmp, text, 'utf8').then(() => fs.rename(tmp, file))
110
+ }
111
+
112
+ /** Windows 对端仍持有句柄时 unlink 可能短暂 EPERM;成功响应后做有界重试清理。 */
113
+ async function removeFileWithRetry(file, attempts = 8) {
114
+ for (let index = 0; index < attempts; index += 1) {
115
+ try { await fs.unlink(file); return true } catch (error) {
116
+ if (error?.code === 'ENOENT') return true
117
+ if (index + 1 < attempts) await sleep(25 * (index + 1))
118
+ }
119
+ }
120
+ return false
121
+ }
122
+
123
+ /**
124
+ * 文件 outbox 兜底传输:同一文件系统、无网络/无 HTTP 时使用。
125
+ * 请求写 requests/<id>.json,对端(startFileContractServer)写 responses/<id>.json。
126
+ */
127
+ export function createFileTransport({ dir = '', fetchImpl = null, pollMs = 100, timeoutMs = 15_000 } = {}) {
128
+ const root = String(dir ?? '').trim()
129
+ const directories = {
130
+ request: path.join(root, 'requests'),
131
+ response: path.join(root, 'responses'),
132
+ }
133
+ const ensure = async () => {
134
+ await fs.mkdir(directories.request, { recursive: true })
135
+ await fs.mkdir(directories.response, { recursive: true })
136
+ }
137
+ return {
138
+ name: 'file', kind: 'file',
139
+ available: () => Boolean(root),
140
+ async hello() {
141
+ if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
142
+ return await this.invoke(METHODS.HELLO, null).then((response) => (response.ok ? response.result : Promise.reject(new ContractTransportError(ERROR_CODES.REMOTE_ERROR, response.error?.message ?? 'hello failed', { transport: 'file' }))))
143
+ },
144
+ async invoke(method, params, opts = {}) {
145
+ if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
146
+ await ensure()
147
+ const envelope = createEnvelope({ method, params, source: 'file', meta: opts.meta, timeoutMs: opts.timeoutMs })
148
+ const requestFile = path.join(directories.request, `${envelope.id}.json`)
149
+ const responseFile = path.join(directories.response, `${envelope.id}.json`)
150
+ await atomicWrite(requestFile, JSON.stringify(envelope))
151
+ const effectiveTimeout = Math.max(1, Number(opts.timeoutMs) || timeoutMs)
152
+ const deadline = Date.now() + effectiveTimeout
153
+ while (Date.now() < deadline) {
154
+ try {
155
+ const text = await fs.readFile(responseFile, 'utf8')
156
+ await removeFileWithRetry(responseFile)
157
+ const parsed = JSON.parse(text)
158
+ const check = validateEnvelope(parsed, { response: true })
159
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'file' })
160
+ // 请求文件由调用方负责回收:服务端实现可能只写响应而不删请求,
161
+ // 成功匹配响应后必须幂等清理,避免 contract-outbox 长期积累。
162
+ await removeFileWithRetry(requestFile)
163
+ return parsed
164
+ } catch (error) {
165
+ if (error instanceof ContractTransportError) throw error
166
+ }
167
+ await sleep(pollMs)
168
+ }
169
+ await removeFileWithRetry(requestFile)
170
+ await removeFileWithRetry(responseFile)
171
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `file contract timeout (>${effectiveTimeout}ms)`, { transport: 'file', retryable: true })
172
+ },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 文件 fallback 的服务端轮询器:读取 requests/,调用 contractServer.invoke,写 responses/。
178
+ * @returns {{stop:()=>void, describe:()=>(object)}}
179
+ */
180
+ export function startFileContractServer({ dir, server, pollMs = 100, maxAgeMs = 60_000, logger = null } = {}) {
181
+ const root = String(dir ?? '').trim()
182
+ if (!root) throw new Error('startFileContractServer requires dir')
183
+ const requestDir = path.join(root, 'requests')
184
+ const responseDir = path.join(root, 'responses')
185
+ let stopped = false
186
+ let timer = null
187
+ const tick = async () => {
188
+ if (stopped) return
189
+ try {
190
+ await fs.mkdir(requestDir, { recursive: true })
191
+ await fs.mkdir(responseDir, { recursive: true })
192
+ const entries = await fs.readdir(requestDir).catch(() => [])
193
+ for (const name of entries) {
194
+ if (!name.endsWith('.json')) continue
195
+ const file = path.join(requestDir, name)
196
+ try {
197
+ const stat = await fs.stat(file)
198
+ if (Date.now() - stat.mtimeMs > maxAgeMs) { await fs.unlink(file).catch(() => {}); continue }
199
+ const envelope = JSON.parse(await fs.readFile(file, 'utf8'))
200
+ const response = await server.invoke(envelope)
201
+ await atomicWrite(path.join(responseDir, `${envelope.id}.json`), JSON.stringify(response))
202
+ await fs.unlink(file).catch(() => {})
203
+ } catch (error) {
204
+ try { logger?.warn?.(`[contract:file] ${name}: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
205
+ await fs.unlink(file).catch(() => {})
206
+ }
207
+ }
208
+ } catch { /* poll must not die */ }
209
+ if (!stopped) timer = setTimeout(tick, pollMs)
210
+ }
211
+ timer = setTimeout(tick, pollMs)
212
+ return {
213
+ stop() { stopped = true; if (timer) clearTimeout(timer); timer = null },
214
+ describe() { return { transport: 'file', dir: root, pollMs, stopped } },
215
+ }
216
+ }
217
+
218
+ /** 旧版 3.1.x 服务键回退:接口包被替换/新端点不存在时仍能工作。 */
219
+ export function createLegacyTransport({ ctx } = {}) {
220
+ const forker = () => safeGet(ctx, LEGACY_SERVICE_KEYS.forker)
221
+ const decision = () => safeGet(ctx, LEGACY_SERVICE_KEYS.decisionCenter)
222
+ const sessions = () => safeGet(ctx, LEGACY_SERVICE_KEYS.sessionCatalog)
223
+ const orchestrator = () => safeGet(ctx, LEGACY_SERVICE_KEYS.orchestrator)
224
+ const available = () => Boolean(forker() || decision() || sessions() || orchestrator())
225
+ return {
226
+ name: 'legacy', kind: 'legacy', available,
227
+ async hello() {
228
+ return {
229
+ protocol: PROTOCOL, contractVersion: '0.9.0', role: 'mother', instanceId: 'legacy',
230
+ methods: [METHODS.HEALTH, METHODS.FORK_SPAWN, METHODS.DECISION_CREATE, METHODS.SESSION_LIST],
231
+ capabilities: ['sync'], transports: ['legacy'],
232
+ }
233
+ },
234
+ async invoke(method, params) {
235
+ if (method === METHODS.HEALTH) return createResponse({ result: { ok: true, legacy: true } })
236
+ if (method === METHODS.FORK_SPAWN) {
237
+ const service = forker()
238
+ if (!service || typeof service.spawn !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangForker is absent', { transport: 'legacy' })
239
+ return createResponse({ result: await service.spawn(params ?? {}) })
240
+ }
241
+ if (method === METHODS.DECISION_CREATE) {
242
+ const service = decision()
243
+ if (!service || typeof service.create !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangDecisionCenter is absent', { transport: 'legacy' })
244
+ return createResponse({ result: await service.create(params ?? {}) })
245
+ }
246
+ if (method === METHODS.SESSION_LIST) {
247
+ const service = sessions()
248
+ if (!service || typeof service.list !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangSessionCatalog is absent', { transport: 'legacy' })
249
+ return createResponse({ result: await service.list(params ?? {}) })
250
+ }
251
+ throw new ContractTransportError(ERROR_CODES.UNSUPPORTED_METHOD, `legacy transport cannot serve ${method}`, { transport: 'legacy' })
252
+ },
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 传输路由器:按 local → http → file → legacy 顺序尝试;
258
+ * 传输不可用/超时/协议不兼容/未知方法时自动降级;业务错误不降级(避免重复副作用)。
259
+ */
260
+ export function createTransportRouter({ transports = [], order = DEFAULT_TRANSPORT_ORDER, localHello = null, logger = null } = {}) {
261
+ const byName = new Map(transports.map((transport) => [transport.name, transport]))
262
+ const ordered = order.map((name) => byName.get(name)).filter((transport) => transport && (!transport.available || transport.available()))
263
+ const attempts = []
264
+ const record = (entry) => { attempts.push({ at: Date.now(), ...entry }); if (attempts.length > 100) attempts.shift() }
265
+ return {
266
+ transports: () => ordered.map((transport) => ({ name: transport.name, kind: transport.kind })),
267
+ attempts: () => [...attempts],
268
+ describe() {
269
+ return {
270
+ order,
271
+ available: ordered.map((transport) => transport.name),
272
+ configured: transports.map((transport) => ({ name: transport.name, kind: transport.kind, available: transport.available ? transport.available() : true })),
273
+ }
274
+ },
275
+ async hello() {
276
+ const local = localHello ?? helloPayload({ role: 'client', instanceId: 'contract-client' })
277
+ for (const transport of ordered) {
278
+ try {
279
+ const remote = await transport.hello()
280
+ const negotiation = negotiate(local, remote)
281
+ record({ transport: transport.name, action: 'hello', ok: true, compatible: negotiation.compatible, reason: negotiation.reason ?? null })
282
+ if (negotiation.compatible) return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: null }
283
+ // remote-too-old/新:若还有后续传输(如 legacy),继续降级。
284
+ if (transport.name !== 'legacy') continue
285
+ return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: 'legacy' }
286
+ } catch (error) {
287
+ record({ transport: transport.name, action: 'hello', ok: false, code: error?.code ?? null })
288
+ try { logger?.warn?.(`[contract] hello via ${transport.name} failed: ${error?.code ?? error?.message ?? error}`) } catch { /* ignore */ }
289
+ }
290
+ }
291
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'no transport could complete hello', attempts: this.attempts() }
292
+ },
293
+ async invoke(method, params, { idempotencyKey = '', timeoutMs = 30_000 } = {}) {
294
+ const envelopeMeta = idempotencyKey ? { idempotencyKey } : {}
295
+ const failures = []
296
+ for (const transport of ordered) {
297
+ try {
298
+ const response = await transport.invoke(method, params, { timeoutMs, meta: envelopeMeta })
299
+ // 有的传输(local)返回 response envelope;统一读取。
300
+ const ok = response?.ok === true
301
+ record({ transport: transport.name, action: method, ok, code: response?.error?.code ?? null })
302
+ if (ok) return { ok: true, transport: transport.name, response }
303
+ const code = String(response?.error?.code ?? '')
304
+ const fallbackable = code === ERROR_CODES.UNSUPPORTED_METHOD || code === ERROR_CODES.UNSUPPORTED_VERSION || response?.error?.retryable === true
305
+ failures.push({ transport: transport.name, code, message: response?.error?.message ?? '' })
306
+ if (!fallbackable) return { ok: false, transport: transport.name, response, error: response?.error ?? normalizeError(code), attempts: this.attempts() }
307
+ } catch (error) {
308
+ const code = error?.code ?? ERROR_CODES.TRANSPORT_UNAVAILABLE
309
+ failures.push({ transport: transport.name, code, message: String(error?.message ?? error) })
310
+ record({ transport: transport.name, action: method, ok: false, code })
311
+ try { logger?.warn?.(`[contract] ${method} via ${transport.name} failed: ${code} ${error?.message ?? ''}`) } catch { /* ignore */ }
312
+ }
313
+ }
314
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'all contract transports failed', failures, attempts: this.attempts() }
315
+ },
316
+ }
317
+ }
318
+
319
+ export { CONTRACT_VERSION, LEGACY_SERVICE_KEYS, METHODS }
package/src/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang package root — the orchestrator plugin (row id: fang-orchestrator).
4
+ // The split-row patch installs each sub-plugin as its own loader row; this entry is
5
+ // the main plugin (2.0 in the product spec) and must stay the BARE package name,
6
+ // because the client-module scanner only maps a bare (two-segment scoped) specifier
7
+ // to a package manifest — a `@scope/pkg/subpath` row never reaches `dsh.client`.
8
+ // If the split rows ever fail to resolve, `./compose` mounts every sub-plugin as a
9
+ // nested fiber from this same package.
10
+ export * from './plugins/orchestrator.mjs'
11
+ export { apply, Config, inject, name } from './plugins/orchestrator.mjs'