@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5
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/APPROVAL-DRILL.md +42 -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/approval-drill.mjs +111 -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 +582 -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 +3979 -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/approval-drill.mjs +21 -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 +425 -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,1468 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// fang-adapter — 分身侧的跨进程桥(v2.0.0 第 11 行,契约:docs/CONTRACTS-V2.md §5.1、
|
|
4
|
+
// docs/PROTOCOL-V2.md §2/§3)。
|
|
5
|
+
//
|
|
6
|
+
// 母体与分身是两个独立 DSH 进程,不能共享 Cordis ctx/bus。本插件把分身内部的服务
|
|
7
|
+
// (scorer/router/healer/approver/hotReloader/buffer/auditor)暴露为 5 个 HTTP 端点,
|
|
8
|
+
// 由母体侧 `src/lib/remote.mjs` 调用:
|
|
9
|
+
//
|
|
10
|
+
// GET /fang/adapter/hello 握手:role / taskId / version / capabilities
|
|
11
|
+
// GET /fang/adapter/status 服务表 + 缓冲三态 + 待批数 + 审计头
|
|
12
|
+
// GET /fang/adapter/approvals 分身本地待审批项
|
|
13
|
+
// GET /fang/adapter/audit 审计尾部(?limit=n)
|
|
14
|
+
// POST /fang/adapter/command 控制命令 {op, payload}
|
|
15
|
+
//
|
|
16
|
+
// v2.6.1 起叠加 v3 接口(契约:docs/PROTOCOL-V3.md §4,向后兼容,旧端点全部保留):
|
|
17
|
+
//
|
|
18
|
+
// GET /fang/adapter/version 协议版本协商(protocol / sessionId / capabilities)
|
|
19
|
+
// GET /fang/adapter/health 轻量健康检查(Forker.waitReady / 存活探测)
|
|
20
|
+
// GET /fang/adapter/state 完整状态快照(带 lastCursor,幂等)
|
|
21
|
+
// GET /fang/adapter/events SSE 事件流(?since=<cursor>&session=<sid>)
|
|
22
|
+
// POST /fang/adapter/commands 幂等命令信封 {commandId, op, payload, async}
|
|
23
|
+
//
|
|
24
|
+
// 认证(v3):Forker spawn 时生成随机 token 经 env `DSH_ADAPTER_TOKEN` 注入,母体每次
|
|
25
|
+
// 请求带 `Authorization: Bearer <token>`;token 不落盘、不写 URL。未注入时退化为
|
|
26
|
+
// v2 语义(交由 DSH web 的 ?token= 守卫),以便旧分身/旧母体平滑共存。
|
|
27
|
+
//
|
|
28
|
+
// 硬约束:
|
|
29
|
+
// * 只部署在分身:apply 首行 roleGate('adapter'),mother 角色下不注册服务/路由;
|
|
30
|
+
// * 处理器与 service 方法**共用同一实现**,HTTP 只做方法校验与状态码映射;
|
|
31
|
+
// * 所有依赖服务通过 `ctx.get(SERVICE.x)` 可选获取,缺失显式降级为
|
|
32
|
+
// `{ok:false, code:'service-unavailable'}`,绝不假装成功、绝不抛出;
|
|
33
|
+
// * read-plugin 只读「本分身 buffer/<pluginId>」内的文本文件,拒绝路径逃逸/UNC/盘符/
|
|
34
|
+
// `..`,单文件 ≤1MB、总量 ≤2MB,二进制(含 NUL)拒绝;
|
|
35
|
+
// * archive = flush buffer + 归档标记(进程 kill 由母体 Forker 执行,绝不自杀)。
|
|
36
|
+
import fs from 'node:fs'
|
|
37
|
+
import path from 'node:path'
|
|
38
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
z,
|
|
42
|
+
roleGate,
|
|
43
|
+
resolveFangLayout,
|
|
44
|
+
SERVICE,
|
|
45
|
+
ADAPTER_OP,
|
|
46
|
+
ADAPTER_ROUTE,
|
|
47
|
+
ADAPTER_EVENT,
|
|
48
|
+
ADAPTER_PROTOCOL_MIN,
|
|
49
|
+
ADAPTER_PROTOCOL_VERSION,
|
|
50
|
+
AUDIT,
|
|
51
|
+
FANG_VERSION,
|
|
52
|
+
bus,
|
|
53
|
+
exists,
|
|
54
|
+
iso,
|
|
55
|
+
newId,
|
|
56
|
+
nowMs,
|
|
57
|
+
sampleResources,
|
|
58
|
+
totalResources,
|
|
59
|
+
} from '../kernel/index.mjs'
|
|
60
|
+
import { authErrorBody, checkRequestAuth } from '../lib/http-auth.mjs'
|
|
61
|
+
import {
|
|
62
|
+
CommandCache,
|
|
63
|
+
EventLog,
|
|
64
|
+
SSE_HEARTBEAT_MS,
|
|
65
|
+
SSE_RETRY_FRAME,
|
|
66
|
+
normalizeCommandEnvelope,
|
|
67
|
+
parseBearer,
|
|
68
|
+
parseCursor,
|
|
69
|
+
sseComment,
|
|
70
|
+
sseFrame,
|
|
71
|
+
statusForAdapterError,
|
|
72
|
+
} from '../lib/adapter-protocol.mjs'
|
|
73
|
+
|
|
74
|
+
export const name = 'fang-adapter'
|
|
75
|
+
export const inject = []
|
|
76
|
+
|
|
77
|
+
/** 单文件 / 单次 read-plugin 总量的硬上限(契约 §5.1、PROTOCOL-V2 §3)。 */
|
|
78
|
+
export const MAX_FILE_BYTES = 1024 * 1024
|
|
79
|
+
export const MAX_TOTAL_BYTES = 2 * 1024 * 1024
|
|
80
|
+
|
|
81
|
+
/** forkId 的环境变量来源(forker 用 DSH_FANG_TASK_ID 注入分身身份)。 */
|
|
82
|
+
const TASK_ID_ENVS = ['DSH_FANG_TASK_ID', 'DSH_FANG_FORK_ID', 'DSH_FANG_FORK', 'FANG_TASK_ID']
|
|
83
|
+
|
|
84
|
+
const DEFAULTS = Object.freeze({
|
|
85
|
+
prefix: '/fang',
|
|
86
|
+
taskId: '',
|
|
87
|
+
auditLimit: 500,
|
|
88
|
+
commandTimeoutMs: 10000,
|
|
89
|
+
token: '',
|
|
90
|
+
eventCapacity: 1000,
|
|
91
|
+
commandCacheSize: 500,
|
|
92
|
+
heartbeatMs: 5000,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
export const Config = z.object({
|
|
96
|
+
prefix: z.string().default('/fang')
|
|
97
|
+
.description('HTTP 路由前缀(与母体 Remote 的 prefix 一致)'),
|
|
98
|
+
taskId: z.string().default('')
|
|
99
|
+
.description('任务 id;留空 = 环境变量 DSH_FANG_TASK_ID / forkId'),
|
|
100
|
+
auditLimit: z.number().min(1).max(100000).default(500)
|
|
101
|
+
.description('单次审计拉取上限(HTTP ?limit= 会被夹在此值内)'),
|
|
102
|
+
commandTimeoutMs: z.number().min(1).max(600000).default(10000)
|
|
103
|
+
.description('单条控制命令的硬超时(毫秒);超时返回 code=timeout,不阻塞 HTTP'),
|
|
104
|
+
token: z.string().default('')
|
|
105
|
+
.description('v3 Bearer token;留空 = 环境变量 DSH_ADAPTER_TOKEN(Forker spawn 时注入)'),
|
|
106
|
+
eventCapacity: z.number().min(1).max(100000).default(1000)
|
|
107
|
+
.description('SSE 事件环形缓冲容量(母体断线后可续传的事件数)'),
|
|
108
|
+
commandCacheSize: z.number().min(1).max(10000).default(500)
|
|
109
|
+
.description('命令幂等缓存容量(LRU,按 commandId)'),
|
|
110
|
+
heartbeatMs: z.number().min(1000).max(60000).default(5000)
|
|
111
|
+
.description('SSE 心跳 + 待审批 diff 周期(毫秒)'),
|
|
112
|
+
allowLegacyNoToken: z.boolean().default(false)
|
|
113
|
+
.description('v3.0.5:fork 角色无 DSH_ADAPTER_TOKEN 时是否允许 v2 兼容(默认 fail-closed)'),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
|
|
117
|
+
|
|
118
|
+
function clampInt(value, min, max, fallback) {
|
|
119
|
+
const n = Math.floor(Number(value))
|
|
120
|
+
if (!Number.isFinite(n)) return fallback
|
|
121
|
+
return Math.min(max, Math.max(min, n))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** '/fang/'、'fang' → '/fang';空值兜底 '/fang'(与 remote.normalizePrefix 同规则)。 */
|
|
125
|
+
export function normalizePrefix(prefix) {
|
|
126
|
+
const raw = String(prefix ?? '/fang').trim().replace(/\\/g, '/')
|
|
127
|
+
if (!raw) return '/fang'
|
|
128
|
+
const withSlash = raw.startsWith('/') ? raw : `/${raw}`
|
|
129
|
+
const trimmed = withSlash.replace(/\/+$/, '')
|
|
130
|
+
return trimmed || '/fang'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveTaskId(cfg = {}) {
|
|
134
|
+
const explicit = typeof cfg.taskId === 'string' ? cfg.taskId.trim() : ''
|
|
135
|
+
if (explicit) return explicit
|
|
136
|
+
for (const key of TASK_ID_ENVS) {
|
|
137
|
+
const v = process.env?.[key]
|
|
138
|
+
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
139
|
+
}
|
|
140
|
+
return ''
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 把用户给的相对路径限制在 `root` 内(拒绝 `..`、绝对路径、盘符、UNC、空路径)。
|
|
145
|
+
* @returns {{ok:true, dir:string} | {ok:false, code:string, error:string}}
|
|
146
|
+
*/
|
|
147
|
+
export function resolveInside(root, relative) {
|
|
148
|
+
const base = String(root ?? '').trim()
|
|
149
|
+
const raw = String(relative ?? '').trim()
|
|
150
|
+
if (!raw) return { ok: false, code: 'bad-request', error: 'empty-path' }
|
|
151
|
+
if (raw.includes('\0')) return { ok: false, code: 'path-escape', error: 'nul-byte-in-path' }
|
|
152
|
+
if (/^[A-Za-z]:/.test(raw)) return { ok: false, code: 'path-escape', error: `absolute-path:${raw}` }
|
|
153
|
+
if (raw.startsWith('\\\\') || raw.startsWith('//')) {
|
|
154
|
+
return { ok: false, code: 'path-escape', error: `unc-path:${raw}` }
|
|
155
|
+
}
|
|
156
|
+
if (path.isAbsolute(raw)) return { ok: false, code: 'path-escape', error: `absolute-path:${raw}` }
|
|
157
|
+
const segments = raw.replace(/\\/g, '/').split('/')
|
|
158
|
+
if (segments.some((seg) => seg === '..')) {
|
|
159
|
+
return { ok: false, code: 'path-escape', error: `parent-traversal:${raw}` }
|
|
160
|
+
}
|
|
161
|
+
const resolvedRoot = path.resolve(base)
|
|
162
|
+
const target = path.resolve(resolvedRoot, raw)
|
|
163
|
+
const rel = path.relative(resolvedRoot, target)
|
|
164
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
165
|
+
return { ok: false, code: 'path-escape', error: `escapes-buffer:${raw}` }
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, dir: target }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 二进制启发式:前 8000 字节里出现 NUL 即判二进制(文本插件源码不会含 NUL)。 */
|
|
171
|
+
export function looksBinary(buf) {
|
|
172
|
+
if (!buf || typeof buf.length !== 'number') return false
|
|
173
|
+
const end = Math.min(buf.length, 8000)
|
|
174
|
+
for (let i = 0; i < end; i++) if (buf[i] === 0) return true
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 递归读取一个插件目录为 `{relPath: text}`。不跟随 symlink/junction(防逃逸),
|
|
180
|
+
* 文件按相对路径排序,失败时返回结构化错误而不是抛。
|
|
181
|
+
*/
|
|
182
|
+
function readPluginTree(dir) {
|
|
183
|
+
const files = {}
|
|
184
|
+
let bytes = 0
|
|
185
|
+
const stack = [{ abs: dir, rel: '' }]
|
|
186
|
+
while (stack.length > 0) {
|
|
187
|
+
const current = stack.pop()
|
|
188
|
+
let entries
|
|
189
|
+
try {
|
|
190
|
+
entries = fs.readdirSync(current.abs, { withFileTypes: true })
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { ok: false, code: 'read-failed', error: `readdir-failed:${msgOf(e)}`, rel: current.rel || null }
|
|
193
|
+
}
|
|
194
|
+
entries.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name
|
|
197
|
+
const abs = path.join(current.abs, entry.name)
|
|
198
|
+
if (entry.isSymbolicLink()) continue // 链接不跟随:junction 可以指向 buffer 之外
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
stack.push({ abs, rel })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (!entry.isFile()) continue
|
|
204
|
+
let stat
|
|
205
|
+
try {
|
|
206
|
+
stat = fs.statSync(abs)
|
|
207
|
+
} catch {
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false, code: 'file-too-large', file: rel, size: stat.size, limit: MAX_FILE_BYTES,
|
|
213
|
+
error: `file-too-large:${rel} (${stat.size} > ${MAX_FILE_BYTES})`,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (bytes + stat.size > MAX_TOTAL_BYTES) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false, code: 'total-too-large', file: rel, bytes: bytes + stat.size, limit: MAX_TOTAL_BYTES,
|
|
219
|
+
error: `total-too-large:${bytes + stat.size} > ${MAX_TOTAL_BYTES}`,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
let buf
|
|
223
|
+
try {
|
|
224
|
+
buf = fs.readFileSync(abs)
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return { ok: false, code: 'read-failed', error: `read-failed:${rel}:${msgOf(e)}`, file: rel }
|
|
227
|
+
}
|
|
228
|
+
if (looksBinary(buf)) {
|
|
229
|
+
return { ok: false, code: 'binary-file', file: rel, error: `binary-file:${rel}` }
|
|
230
|
+
}
|
|
231
|
+
bytes += buf.length
|
|
232
|
+
files[rel] = buf.toString('utf8')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, files, count: Object.keys(files).length, bytes }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 角色 → Web 端身份 kind(纯函数,便于测试)。
|
|
240
|
+
* 母体 = mother;元宝运行态 = essence(DSH_FANG_ESSENCE 或 rt- forkId);
|
|
241
|
+
* 其余 fork 实例 = 任务分身。
|
|
242
|
+
* @param {{role?:string, taskId?:string, essence?:boolean}} [input]
|
|
243
|
+
* @returns {'mother'|'essence'|'fork'}
|
|
244
|
+
*/
|
|
245
|
+
export function identityKind({ role = 'fork', taskId = '', essence = false } = {}) {
|
|
246
|
+
if (role === 'mother') return 'mother'
|
|
247
|
+
return essence === true || String(taskId ?? '').startsWith('rt-') ? 'essence' : 'fork'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function apply(ctx, config) {
|
|
251
|
+
const gate = roleGate('adapter', { logger: ctx.logger })
|
|
252
|
+
if (!gate.active) return
|
|
253
|
+
|
|
254
|
+
// schemastery 的 Config({}) 负责填默认值;后面再叠一层裸对象,宿主/测试绕过 schema 也不炸。
|
|
255
|
+
let configDefaults = {}
|
|
256
|
+
try {
|
|
257
|
+
configDefaults = Config({}) ?? {}
|
|
258
|
+
} catch {
|
|
259
|
+
configDefaults = {}
|
|
260
|
+
}
|
|
261
|
+
const cfg = { ...DEFAULTS, ...configDefaults, ...(config ?? {}) }
|
|
262
|
+
|
|
263
|
+
const prefix = normalizePrefix(cfg.prefix)
|
|
264
|
+
const taskId = resolveTaskId(cfg)
|
|
265
|
+
const auditLimit = clampInt(cfg.auditLimit, 1, 100000, DEFAULTS.auditLimit)
|
|
266
|
+
const commandTimeoutMs = clampInt(cfg.commandTimeoutMs, 1, 600000, DEFAULTS.commandTimeoutMs)
|
|
267
|
+
const eventCapacity = clampInt(cfg.eventCapacity, 1, 100000, DEFAULTS.eventCapacity)
|
|
268
|
+
const commandCacheSize = clampInt(cfg.commandCacheSize, 1, 10000, DEFAULTS.commandCacheSize)
|
|
269
|
+
const heartbeatMs = clampInt(cfg.heartbeatMs, 1000, 60000, DEFAULTS.heartbeatMs)
|
|
270
|
+
const role = gate.role
|
|
271
|
+
const startedAt = nowMs()
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* v3 进程实例 id:只要进程重启就换一个。母体据此判断「对端是不是同一个分身」,
|
|
275
|
+
* 避免把旧 cursor 用在新进程上(PROTOCOL-V3 §7.3)。
|
|
276
|
+
*/
|
|
277
|
+
const sessionId = `s-${randomBytes(8).toString('hex')}`
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Bearer token:显式配置 > env `DSH_ADAPTER_TOKEN`(Forker spawn 时注入,不落盘)。
|
|
281
|
+
* 两者都没有 = v2 兼容模式:仍由 DSH web 自身的 ?token= 守卫,Adapter 不再加一层。
|
|
282
|
+
*/
|
|
283
|
+
const adapterToken = (() => {
|
|
284
|
+
const explicit = String(cfg.token ?? '').trim()
|
|
285
|
+
if (explicit) return explicit
|
|
286
|
+
const fromEnv = typeof process.env?.DSH_ADAPTER_TOKEN === 'string' ? process.env.DSH_ADAPTER_TOKEN.trim() : ''
|
|
287
|
+
return fromEnv
|
|
288
|
+
})()
|
|
289
|
+
const expectedTokenBuf = adapterToken ? Buffer.from(adapterToken, 'utf8') : null
|
|
290
|
+
// v3.0.5/B2:fork 角色无 token 时 fail-closed(v2 兼容仅限显式 allowLegacyNoToken)
|
|
291
|
+
const isForkRole = role === 'fork'
|
|
292
|
+
const legacyAllowed = !isForkRole || cfg.allowLegacyNoToken === true
|
|
293
|
+
if (isForkRole && expectedTokenBuf === null && !legacyAllowed) {
|
|
294
|
+
ctx.logger?.warn?.('[adapter] fork 角色未配置 DSH_ADAPTER_TOKEN,已按 fail-closed 拒绝控制面请求')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 恒定时间比较;长度不同直接 false(不泄露长度信息之外的任何东西)。 */
|
|
298
|
+
const authorized = (req) => {
|
|
299
|
+
if (!expectedTokenBuf) return legacyAllowed
|
|
300
|
+
const got = parseBearer(req?.headers?.authorization ?? req?.headers?.Authorization)
|
|
301
|
+
if (!got) return false
|
|
302
|
+
const actual = Buffer.from(got, 'utf8')
|
|
303
|
+
if (actual.length !== expectedTokenBuf.length) return false
|
|
304
|
+
try {
|
|
305
|
+
return timingSafeEqual(actual, expectedTokenBuf)
|
|
306
|
+
} catch {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── v3 状态同步基础设施:事件环形缓冲 + 订阅扇出 + 命令幂等缓存 ─────────────
|
|
312
|
+
const eventLog = new EventLog({ capacity: eventCapacity, sessionId })
|
|
313
|
+
const commandCache = new CommandCache({ capacity: commandCacheSize })
|
|
314
|
+
const subscribers = new Set()
|
|
315
|
+
const activeStreams = new Set()
|
|
316
|
+
|
|
317
|
+
const publish = (event) => {
|
|
318
|
+
let envelope = null
|
|
319
|
+
try {
|
|
320
|
+
envelope = eventLog.append(event)
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
for (const sink of subscribers) {
|
|
325
|
+
try {
|
|
326
|
+
sink(envelope)
|
|
327
|
+
} catch {
|
|
328
|
+
/* 单个 SSE 连接写失败不得影响其它连接与命令处理 */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return envelope
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const subscribe = (sink) => {
|
|
335
|
+
if (typeof sink !== 'function') return () => {}
|
|
336
|
+
subscribers.add(sink)
|
|
337
|
+
return () => subscribers.delete(sink)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 待审批 diff:审批项没有「requested」事件,靠 pending() 快照比对;决议有 bus 事件。
|
|
341
|
+
const knownApprovals = new Map()
|
|
342
|
+
const decidedOutcomes = new Map()
|
|
343
|
+
|
|
344
|
+
const offApprovalDecided = (() => {
|
|
345
|
+
try {
|
|
346
|
+
if (typeof bus?.on !== 'function') return null
|
|
347
|
+
return bus.on('fang/approval-decided', (payload) => {
|
|
348
|
+
const id = String(payload?.id ?? payload?.approvalId ?? '').trim()
|
|
349
|
+
if (!id) return
|
|
350
|
+
const outcome = String(payload?.outcome ?? 'resolved')
|
|
351
|
+
const verdict = outcome === 'approved' ? 'approve' : outcome === 'denied' ? 'reject' : outcome
|
|
352
|
+
decidedOutcomes.set(id, verdict)
|
|
353
|
+
if (knownApprovals.has(id)) {
|
|
354
|
+
knownApprovals.delete(id)
|
|
355
|
+
publish({ kind: ADAPTER_EVENT.APPROVAL_RESOLVED, approvalId: id, verdict })
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
} catch {
|
|
359
|
+
return null
|
|
360
|
+
}
|
|
361
|
+
})()
|
|
362
|
+
if (typeof offApprovalDecided === 'function') {
|
|
363
|
+
ctx.effect(() => () => {
|
|
364
|
+
try {
|
|
365
|
+
offApprovalDecided()
|
|
366
|
+
} catch {
|
|
367
|
+
/* ignore */
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let layout = null
|
|
373
|
+
try {
|
|
374
|
+
layout = resolveFangLayout()
|
|
375
|
+
} catch {
|
|
376
|
+
layout = null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let archivedAt = null
|
|
380
|
+
|
|
381
|
+
// ── 基础设施(全部容错:任何依赖缺失都不得让 HTTP 处理器抛出)──────────────
|
|
382
|
+
const safeGet = (key) => {
|
|
383
|
+
try {
|
|
384
|
+
return ctx.get(key)
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const hasService = (key, method) => {
|
|
390
|
+
const svc = safeGet(key)
|
|
391
|
+
if (!svc) return false
|
|
392
|
+
return method ? typeof svc[method] === 'function' : true
|
|
393
|
+
}
|
|
394
|
+
const unavailable = () => ({ ok: false, code: 'service-unavailable', error: 'service-unavailable' })
|
|
395
|
+
const badRequest = (error) => ({ ok: false, code: 'bad-request', error: String(error ?? 'bad-request') })
|
|
396
|
+
const emit = (topic, payload) => {
|
|
397
|
+
try {
|
|
398
|
+
bus.emit(topic, payload)
|
|
399
|
+
} catch {
|
|
400
|
+
/* bus 只做通知,失败不影响命令 */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const appendAudit = (kind, data) => {
|
|
404
|
+
try {
|
|
405
|
+
const auditor = safeGet(SERVICE.auditor)
|
|
406
|
+
if (auditor && typeof auditor.append === 'function') auditor.append(kind, data)
|
|
407
|
+
} catch {
|
|
408
|
+
/* 审计失败不得阻塞控制面 */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 本分身自己的 buffer 根目录:优先 BufferKeeper 真实目录,其次布局默认值。 */
|
|
413
|
+
const bufferDir = () => {
|
|
414
|
+
const buffer = safeGet(SERVICE.buffer)
|
|
415
|
+
try {
|
|
416
|
+
if (buffer && typeof buffer.dir === 'string' && buffer.dir) return path.resolve(buffer.dir)
|
|
417
|
+
} catch {
|
|
418
|
+
/* getter 抛错则继续降级 */
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const state = buffer && typeof buffer.state === 'function' ? buffer.state() : null
|
|
422
|
+
if (state && typeof state.dir === 'string' && state.dir) return path.resolve(state.dir)
|
|
423
|
+
} catch {
|
|
424
|
+
/* ignore */
|
|
425
|
+
}
|
|
426
|
+
const fallback = layout?.bufferDir
|
|
427
|
+
return fallback ? path.resolve(fallback) : ''
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const bufferView = () => {
|
|
431
|
+
const buffer = safeGet(SERVICE.buffer)
|
|
432
|
+
try {
|
|
433
|
+
const state = buffer && typeof buffer.state === 'function' ? buffer.state() : null
|
|
434
|
+
if (state && typeof state === 'object') {
|
|
435
|
+
return {
|
|
436
|
+
state: typeof state.state === 'string' ? state.state : 'unknown',
|
|
437
|
+
pending: Array.isArray(state.pending) ? [...state.pending] : [],
|
|
438
|
+
dir: typeof state.dir === 'string' ? state.dir : bufferDir(),
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
/* ignore */
|
|
443
|
+
}
|
|
444
|
+
return { state: 'unavailable', pending: [], dir: bufferDir() }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** null = approver 服务缺失;[] = 服务在但没有待办(两者语义不同)。 */
|
|
448
|
+
const approvalItems = () => {
|
|
449
|
+
const approver = safeGet(SERVICE.approver)
|
|
450
|
+
if (!approver || typeof approver.pending !== 'function') return null
|
|
451
|
+
try {
|
|
452
|
+
const items = approver.pending()
|
|
453
|
+
return Array.isArray(items) ? items : []
|
|
454
|
+
} catch {
|
|
455
|
+
return []
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const orphanedApprovalItems = () => {
|
|
460
|
+
const approver = safeGet(SERVICE.approver)
|
|
461
|
+
if (!approver || typeof approver.orphaned !== 'function') return []
|
|
462
|
+
try {
|
|
463
|
+
const items = approver.orphaned()
|
|
464
|
+
return Array.isArray(items) ? items : []
|
|
465
|
+
} catch {
|
|
466
|
+
return []
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const approvalItemsWithOrphans = () => {
|
|
471
|
+
const pending = approvalItems()
|
|
472
|
+
if (pending === null) return null
|
|
473
|
+
return [...pending, ...orphanedApprovalItems()]
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const auditHead = () => {
|
|
477
|
+
const auditor = safeGet(SERVICE.auditor)
|
|
478
|
+
if (!auditor || typeof auditor.read !== 'function') {
|
|
479
|
+
return { records: 0, head: null, degraded: 'service-unavailable' }
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
const records = auditor.read({ limit: Number.MAX_SAFE_INTEGER })
|
|
483
|
+
const list = Array.isArray(records) ? records : []
|
|
484
|
+
const last = list.length > 0 ? list[list.length - 1] : null
|
|
485
|
+
let head = null
|
|
486
|
+
if (last && typeof last === 'object') {
|
|
487
|
+
if (typeof last.hash === 'string') head = last.hash
|
|
488
|
+
else if (Number.isFinite(last.seq)) head = last.seq
|
|
489
|
+
}
|
|
490
|
+
return { records: list.length, head }
|
|
491
|
+
} catch (e) {
|
|
492
|
+
return { records: 0, head: null, degraded: `read-failed:${msgOf(e)}` }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const servicesView = () => ({
|
|
497
|
+
scorer: hasService(SERVICE.scorer),
|
|
498
|
+
umbrella: hasService(SERVICE.umbrella),
|
|
499
|
+
router: hasService(SERVICE.router),
|
|
500
|
+
healer: hasService(SERVICE.healer),
|
|
501
|
+
approver: hasService(SERVICE.approver),
|
|
502
|
+
hotReloader: hasService(SERVICE.hotReloader),
|
|
503
|
+
buffer: hasService(SERVICE.buffer),
|
|
504
|
+
auditor: hasService(SERVICE.auditor),
|
|
505
|
+
adapter: true,
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// ── v3 快照 / 健康 / 版本 / 审批 diff(docs/PROTOCOL-V3.md §5)─────────────
|
|
509
|
+
const activeSessions = () => {
|
|
510
|
+
const catalog = safeGet(SERVICE.sessionCatalog)
|
|
511
|
+
if (!catalog || typeof catalog.list !== 'function') return null
|
|
512
|
+
try {
|
|
513
|
+
const list = catalog.list()
|
|
514
|
+
return Array.isArray(list) ? list.length : null
|
|
515
|
+
} catch {
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const metricsView = () => {
|
|
521
|
+
let memory = null
|
|
522
|
+
try {
|
|
523
|
+
memory = process.memoryUsage()
|
|
524
|
+
} catch {
|
|
525
|
+
memory = null
|
|
526
|
+
}
|
|
527
|
+
let cpu = null
|
|
528
|
+
try {
|
|
529
|
+
const usage = process.cpuUsage()
|
|
530
|
+
cpu = { userMs: Math.round(usage.user / 1000), systemMs: Math.round(usage.system / 1000) }
|
|
531
|
+
} catch {
|
|
532
|
+
cpu = null
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
rssBytes: memory?.rss ?? null,
|
|
536
|
+
heapUsedBytes: memory?.heapUsed ?? null,
|
|
537
|
+
handleCount: totalResources(sampleResources()),
|
|
538
|
+
cpu,
|
|
539
|
+
activeSessions: activeSessions(),
|
|
540
|
+
pid: process.pid ?? null,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 审批项归一化:不假设 approver.pending() 的字段全在(缺失就 null,不假装有值)。 */
|
|
545
|
+
const approvalSummary = (item) => ({
|
|
546
|
+
approvalId: String(item?.id ?? item?.approvalId ?? ''),
|
|
547
|
+
action: String(item?.action ?? item?.summary ?? item?.reason ?? item?.title ?? ''),
|
|
548
|
+
light: item?.light ?? item?.tone ?? item?.risk ?? null,
|
|
549
|
+
createdAt: item?.createdAt ?? item?.at ?? null,
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
/** 待审批 diff → approval-requested / approval-resolved 事件(环形缓冲,SSE 顺序一致)。 */
|
|
553
|
+
const syncApprovals = () => {
|
|
554
|
+
const rawItems = approvalItems()
|
|
555
|
+
const items = Array.isArray(rawItems) ? rawItems : []
|
|
556
|
+
const seen = new Set()
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
const summary = approvalSummary(item)
|
|
559
|
+
if (!summary.approvalId) continue
|
|
560
|
+
seen.add(summary.approvalId)
|
|
561
|
+
if (knownApprovals.has(summary.approvalId)) continue
|
|
562
|
+
knownApprovals.set(summary.approvalId, summary)
|
|
563
|
+
publish({ kind: ADAPTER_EVENT.APPROVAL_REQUESTED, approval: summary })
|
|
564
|
+
}
|
|
565
|
+
for (const id of [...knownApprovals.keys()]) {
|
|
566
|
+
if (seen.has(id)) continue
|
|
567
|
+
knownApprovals.delete(id)
|
|
568
|
+
const verdict = decidedOutcomes.get(id) ?? 'resolved'
|
|
569
|
+
decidedOutcomes.delete(id)
|
|
570
|
+
publish({ kind: ADAPTER_EVENT.APPROVAL_RESOLVED, approvalId: id, verdict })
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** 完整状态快照:幂等、无 cursor 参数,母体 UI / 首帧同步用。 */
|
|
575
|
+
const stateSnapshot = () => {
|
|
576
|
+
const rawItems = approvalItems()
|
|
577
|
+
const items = Array.isArray(rawItems) ? rawItems : []
|
|
578
|
+
const buffer = bufferView()
|
|
579
|
+
const audit = auditHead()
|
|
580
|
+
const services = servicesView()
|
|
581
|
+
return {
|
|
582
|
+
ok: true,
|
|
583
|
+
sessionId,
|
|
584
|
+
protocolVersion: ADAPTER_PROTOCOL_VERSION,
|
|
585
|
+
minProtocol: ADAPTER_PROTOCOL_MIN,
|
|
586
|
+
version: FANG_VERSION,
|
|
587
|
+
role,
|
|
588
|
+
taskId,
|
|
589
|
+
status: archivedAt !== null ? 'stopping' : 'running',
|
|
590
|
+
uptimeMs: nowMs() - startedAt,
|
|
591
|
+
at: nowMs(),
|
|
592
|
+
metrics: metricsView(),
|
|
593
|
+
services,
|
|
594
|
+
plugins: Object.entries(services).map(([pluginId, present]) => ({
|
|
595
|
+
pluginId,
|
|
596
|
+
state: present ? 'active' : 'unavailable',
|
|
597
|
+
})),
|
|
598
|
+
approvals: items.map((item) => approvalSummary(item)),
|
|
599
|
+
buffer: {
|
|
600
|
+
state: buffer.state,
|
|
601
|
+
pending: buffer.pending.length,
|
|
602
|
+
dirtyFiles: buffer.pending.length,
|
|
603
|
+
dir: buffer.dir,
|
|
604
|
+
},
|
|
605
|
+
audit: { records: audit.records, head: audit.head, degraded: audit.degraded ?? null },
|
|
606
|
+
archived: archivedAt !== null,
|
|
607
|
+
lastCursor: eventLog.lastCursor,
|
|
608
|
+
nextCursor: eventLog.nextCursor,
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const versionInfo = () => ({
|
|
613
|
+
ok: true,
|
|
614
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
615
|
+
minProtocol: ADAPTER_PROTOCOL_MIN,
|
|
616
|
+
version: FANG_VERSION,
|
|
617
|
+
sessionId,
|
|
618
|
+
taskId,
|
|
619
|
+
role,
|
|
620
|
+
capabilities: Object.values(ADAPTER_OP),
|
|
621
|
+
at: iso(),
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
const health = () => ({
|
|
625
|
+
ok: true,
|
|
626
|
+
status: archivedAt !== null ? 'archived' : 'running',
|
|
627
|
+
pid: process.pid ?? null,
|
|
628
|
+
taskId,
|
|
629
|
+
sessionId,
|
|
630
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
631
|
+
uptimeMs: nowMs() - startedAt,
|
|
632
|
+
archived: archivedAt !== null,
|
|
633
|
+
at: iso(),
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
const state = () => {
|
|
637
|
+
syncApprovals()
|
|
638
|
+
return stateSnapshot()
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* 事件续传:session 不匹配 → session-changed(母体重置 cursor);
|
|
643
|
+
* cursor 过期 → gap=true(HTTP 层回 410,母体回退到 state 快照)。
|
|
644
|
+
*/
|
|
645
|
+
const eventsSince = (cursor, session) => {
|
|
646
|
+
const sid = String(session ?? '').trim()
|
|
647
|
+
if (sid && sid !== sessionId) {
|
|
648
|
+
return { ok: false, code: 'session-changed', sessionId, events: [], gap: false }
|
|
649
|
+
}
|
|
650
|
+
const normalizedCursor = cursor === undefined || cursor === null || cursor === '' ? '0' : cursor
|
|
651
|
+
const result = eventLog.since(normalizedCursor)
|
|
652
|
+
if (!result.ok) return { ok: false, code: result.code, events: [], gap: false, sessionId }
|
|
653
|
+
return {
|
|
654
|
+
ok: true,
|
|
655
|
+
events: result.events,
|
|
656
|
+
gap: result.gap,
|
|
657
|
+
reason: result.reason,
|
|
658
|
+
lastCursor: result.lastCursor,
|
|
659
|
+
nextCursor: result.nextCursor,
|
|
660
|
+
sessionId,
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── service 方法(HTTP 处理器与母体 Remote 共用同一实现)────────────────────
|
|
665
|
+
|
|
666
|
+
const hello = () => ({
|
|
667
|
+
ok: true,
|
|
668
|
+
role,
|
|
669
|
+
taskId, // Web 端身份徽标用:母体 / 元主(元宝运行态)/ 分身(任务)。
|
|
670
|
+
// 元宝运行态由 orchestrator 注入 DSH_FANG_ESSENCE=1;旧运行态按 rt- 前缀兜底。
|
|
671
|
+
kind: identityKind({ role, taskId, essence: typeof process !== 'undefined' && process.env?.DSH_FANG_ESSENCE === '1' }),
|
|
672
|
+
version: FANG_VERSION,
|
|
673
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
674
|
+
sessionId,
|
|
675
|
+
capabilities: Object.values(ADAPTER_OP),
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const status = () => {
|
|
679
|
+
const items = approvalItems()
|
|
680
|
+
return {
|
|
681
|
+
ok: true,
|
|
682
|
+
taskId,
|
|
683
|
+
role,
|
|
684
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
685
|
+
sessionId,
|
|
686
|
+
lastCursor: eventLog.lastCursor,
|
|
687
|
+
services: servicesView(),
|
|
688
|
+
buffer: bufferView(),
|
|
689
|
+
approvals: { pending: items ? items.length : 0 },
|
|
690
|
+
audit: auditHead(),
|
|
691
|
+
startedAt,
|
|
692
|
+
archived: archivedAt !== null,
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// v3.1.14:Web 端拿不到 adapter Bearer token,身份探测走公开只读路由;
|
|
697
|
+
// 只暴露 role/kind/version,不含 taskId/sessionId/token。
|
|
698
|
+
let clientReported = false
|
|
699
|
+
let clientReportedAt = 0
|
|
700
|
+
|
|
701
|
+
const identity = () => ({
|
|
702
|
+
ok: true,
|
|
703
|
+
role,
|
|
704
|
+
kind: identityKind({ role, taskId, essence: typeof process !== 'undefined' && process.env?.DSH_FANG_ESSENCE === '1' }),
|
|
705
|
+
version: FANG_VERSION,
|
|
706
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
707
|
+
// v3.1.17:一眼判断「服务端有身份、客户端没加载」——不含 taskId/token 等敏感字段。
|
|
708
|
+
surface: 'web-adapter',
|
|
709
|
+
clientLoaded: clientReported,
|
|
710
|
+
clientReportedAt: clientReportedAt || null,
|
|
711
|
+
})
|
|
712
|
+
/**
|
|
713
|
+
* 客户端自报:Web 端加载了 fang client 后会 POST 到这里;
|
|
714
|
+
* 只记录时间,不记录任何身份/token(用于运维排查徽标不显示)。
|
|
715
|
+
*/
|
|
716
|
+
const reportClient = () => {
|
|
717
|
+
clientReported = true
|
|
718
|
+
clientReportedAt = Date.now()
|
|
719
|
+
return { ok: true, clientReported: true, at: clientReportedAt }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const approvals = () => {
|
|
723
|
+
const items = approvalItemsWithOrphans()
|
|
724
|
+
if (items === null) return { ok: false, code: 'service-unavailable', error: 'service-unavailable', items: [] }
|
|
725
|
+
return { ok: true, items }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const audit = (options = {}) => {
|
|
729
|
+
const opts = options && typeof options === 'object' ? options : {}
|
|
730
|
+
let requested = 100
|
|
731
|
+
if (opts.limit !== undefined && opts.limit !== null) {
|
|
732
|
+
const n = Math.floor(Number(opts.limit))
|
|
733
|
+
requested = Number.isFinite(n) ? n : 100
|
|
734
|
+
}
|
|
735
|
+
const limit = Math.min(Math.max(0, requested), auditLimit)
|
|
736
|
+
const auditor = safeGet(SERVICE.auditor)
|
|
737
|
+
if (!auditor || typeof auditor.read !== 'function') {
|
|
738
|
+
return { ok: false, code: 'service-unavailable', error: 'service-unavailable', records: [] }
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const records = auditor.read({ limit })
|
|
742
|
+
return { ok: true, records: Array.isArray(records) ? records : [] }
|
|
743
|
+
} catch (e) {
|
|
744
|
+
return { ok: false, code: 'handler-threw', error: msgOf(e), records: [] }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 读 buffer/<pluginId> 下的文本文件(契约 §5.1)。 */
|
|
749
|
+
const readPlugin = (payload = {}) => {
|
|
750
|
+
const pluginId = String(payload?.pluginId ?? '').trim()
|
|
751
|
+
if (!pluginId) return badRequest('pluginId-required')
|
|
752
|
+
const root = bufferDir()
|
|
753
|
+
if (!root) return { ok: false, code: 'not-configured', error: 'buffer-root-unknown' }
|
|
754
|
+
const resolved = resolveInside(root, pluginId)
|
|
755
|
+
if (!resolved.ok) return resolved
|
|
756
|
+
if (!exists(resolved.dir)) {
|
|
757
|
+
return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${pluginId}`, pluginId }
|
|
758
|
+
}
|
|
759
|
+
let stat
|
|
760
|
+
try {
|
|
761
|
+
stat = fs.statSync(resolved.dir)
|
|
762
|
+
} catch (e) {
|
|
763
|
+
return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${msgOf(e)}`, pluginId }
|
|
764
|
+
}
|
|
765
|
+
if (!stat.isDirectory()) return badRequest(`not-a-directory:${pluginId}`)
|
|
766
|
+
const tree = readPluginTree(resolved.dir)
|
|
767
|
+
if (!tree.ok) return tree
|
|
768
|
+
return { files: tree.files, count: tree.count, bytes: tree.bytes, pluginId }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 解析并(在需要时)补齐 route-confirm 的 assessment:payload 可只带 pluginId。 */
|
|
772
|
+
const routeConfirmInput = (payload) => {
|
|
773
|
+
const input = payload && typeof payload === 'object' ? { ...payload } : {}
|
|
774
|
+
if (input.assessment || !input.pluginId) return input
|
|
775
|
+
const scorer = safeGet(SERVICE.scorer)
|
|
776
|
+
if (!scorer || typeof scorer.scoreDir !== 'function') return input
|
|
777
|
+
const root = bufferDir()
|
|
778
|
+
const resolved = root ? resolveInside(root, String(input.pluginId)) : { ok: false }
|
|
779
|
+
if (!resolved.ok || !exists(resolved.dir)) return input
|
|
780
|
+
try {
|
|
781
|
+
input.assessment = scorer.scoreDir(resolved.dir)
|
|
782
|
+
} catch {
|
|
783
|
+
/* assessment 可缺省:Router.confirm 应自行降级 */
|
|
784
|
+
}
|
|
785
|
+
return input
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const flushBuffer = async (payload) => {
|
|
789
|
+
const buffer = safeGet(SERVICE.buffer)
|
|
790
|
+
if (!buffer || typeof buffer.flush !== 'function') return unavailable()
|
|
791
|
+
let opts = {}
|
|
792
|
+
if (payload && typeof payload === 'object') {
|
|
793
|
+
opts = payload.strictMs !== undefined ? { strictMs: payload.strictMs } : { ...payload }
|
|
794
|
+
}
|
|
795
|
+
const result = await buffer.flush(opts)
|
|
796
|
+
if (result && typeof result === 'object') return result
|
|
797
|
+
return { state: 'unknown', flushed: [], timedOut: false }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* 控制命令分发表。每个 handler 返回「结果对象」;返回 `{ok:false, code}` 时作为
|
|
802
|
+
* 失败信封传播(依赖服务缺失 / 参数非法 / 路径逃逸…)。
|
|
803
|
+
*/
|
|
804
|
+
const HANDLERS = new Map([
|
|
805
|
+
[ADAPTER_OP.PING, () => ({ pong: true, at: iso(), pid: process.pid, taskId, uptimeMs: nowMs() - startedAt })],
|
|
806
|
+
|
|
807
|
+
[ADAPTER_OP.STATUS, () => status()],
|
|
808
|
+
|
|
809
|
+
[ADAPTER_OP.FLUSH_BUFFER, (payload) => flushBuffer(payload)],
|
|
810
|
+
|
|
811
|
+
[ADAPTER_OP.SCORE_PLUGIN, (payload) => {
|
|
812
|
+
const scorer = safeGet(SERVICE.scorer)
|
|
813
|
+
if (!scorer || typeof scorer.scoreDir !== 'function') return unavailable()
|
|
814
|
+
const pluginId = String(payload?.pluginId ?? '').trim()
|
|
815
|
+
if (!pluginId) return badRequest('pluginId-required')
|
|
816
|
+
const root = bufferDir()
|
|
817
|
+
if (!root) return { ok: false, code: 'not-configured', error: 'buffer-root-unknown' }
|
|
818
|
+
const resolved = resolveInside(root, pluginId)
|
|
819
|
+
if (!resolved.ok) return resolved
|
|
820
|
+
if (!exists(resolved.dir)) {
|
|
821
|
+
return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${pluginId}`, pluginId }
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
const assessment = scorer.scoreDir(resolved.dir)
|
|
825
|
+
return { ...(assessment && typeof assessment === 'object' ? assessment : {}), pluginId, dir: resolved.dir }
|
|
826
|
+
} catch (e) {
|
|
827
|
+
return { ok: false, code: 'handler-threw', error: `score-failed:${msgOf(e)}` }
|
|
828
|
+
}
|
|
829
|
+
}],
|
|
830
|
+
|
|
831
|
+
[ADAPTER_OP.DECISION_INGEST, async (payload) => {
|
|
832
|
+
const center = safeGet(SERVICE.decisionCenter)
|
|
833
|
+
if (!center || typeof center.submit !== 'function') return unavailable()
|
|
834
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return badRequest('payload-required')
|
|
835
|
+
try {
|
|
836
|
+
const out = await center.submit({ producer: 'adapter', ...payload })
|
|
837
|
+
return out && typeof out === 'object' ? out : { ok: true }
|
|
838
|
+
} catch (e) {
|
|
839
|
+
return { ok: false, code: 'handler-threw', error: `decision-ingest-failed:${msgOf(e)}` }
|
|
840
|
+
}
|
|
841
|
+
}],
|
|
842
|
+
|
|
843
|
+
[ADAPTER_OP.READ_PLUGIN, (payload) => readPlugin(payload)],
|
|
844
|
+
|
|
845
|
+
[ADAPTER_OP.ROUTE_CONFIRM, async (payload) => {
|
|
846
|
+
const router = safeGet(SERVICE.router)
|
|
847
|
+
if (!router || typeof router.confirm !== 'function') return unavailable()
|
|
848
|
+
const out = await router.confirm(routeConfirmInput(payload))
|
|
849
|
+
return out && typeof out === 'object' ? out : { route: null }
|
|
850
|
+
}],
|
|
851
|
+
|
|
852
|
+
[ADAPTER_OP.APPROVALS, () => {
|
|
853
|
+
const items = approvalItemsWithOrphans()
|
|
854
|
+
if (items === null) return unavailable()
|
|
855
|
+
return { items }
|
|
856
|
+
}],
|
|
857
|
+
|
|
858
|
+
[ADAPTER_OP.APPROVAL_DECIDE, (payload) => {
|
|
859
|
+
const approver = safeGet(SERVICE.approver)
|
|
860
|
+
if (!approver || typeof approver.decide !== 'function') return unavailable()
|
|
861
|
+
const id = String(payload?.id ?? payload?.approvalId ?? '').trim()
|
|
862
|
+
if (!id) return badRequest('id-required')
|
|
863
|
+
const raw = payload?.outcome ?? payload?.decision
|
|
864
|
+
const outcome = raw === 'approve' ? 'approved' : raw === 'deny' ? 'denied' : String(raw ?? '')
|
|
865
|
+
if (outcome !== 'approved' && outcome !== 'denied') {
|
|
866
|
+
return badRequest(`bad-outcome:${String(raw ?? '')}`)
|
|
867
|
+
}
|
|
868
|
+
const options = {
|
|
869
|
+
by: String(payload?.by ?? 'adapter'),
|
|
870
|
+
note: payload?.note === undefined || payload?.note === null ? null : String(payload.note),
|
|
871
|
+
}
|
|
872
|
+
const decided = approver.decide(id, outcome, options)
|
|
873
|
+
if (decided && decided.ok === false) {
|
|
874
|
+
const code = decided.reason === 'not-found' ? 'not-found'
|
|
875
|
+
: decided.reason === 'bad-outcome' ? 'bad-request' : 'conflict'
|
|
876
|
+
return { ok: false, code, error: String(decided.reason ?? 'decide-failed'), decided }
|
|
877
|
+
}
|
|
878
|
+
return decided && typeof decided === 'object' ? decided : { ok: true, id, outcome }
|
|
879
|
+
}],
|
|
880
|
+
|
|
881
|
+
[ADAPTER_OP.ARCHIVE, async (payload) => {
|
|
882
|
+
// archive 的语义是「flush + 归档标记」,buffer 是必需依赖;进程 kill 由母体做。
|
|
883
|
+
const flushed = await flushBuffer(payload?.flushOpts ?? {})
|
|
884
|
+
if (flushed && flushed.ok === false && flushed.code === 'service-unavailable') return flushed
|
|
885
|
+
archivedAt = nowMs()
|
|
886
|
+
appendAudit(AUDIT.TASK_ARCHIVED, {
|
|
887
|
+
taskId,
|
|
888
|
+
archivedAt: iso(archivedAt),
|
|
889
|
+
flushed: Array.isArray(flushed?.flushed) ? flushed.flushed : [],
|
|
890
|
+
})
|
|
891
|
+
emit('fang/task-archived', { taskId })
|
|
892
|
+
return {
|
|
893
|
+
archived: true,
|
|
894
|
+
archivedAt,
|
|
895
|
+
flushed: Array.isArray(flushed?.flushed) ? flushed.flushed : [],
|
|
896
|
+
}
|
|
897
|
+
}],
|
|
898
|
+
|
|
899
|
+
// 方案 A:物理分支。分身会话只存在分身自己的 DSH 进程里,母体侧够不到,
|
|
900
|
+
// 所以在分身宿主内调用 sessions.fork,把子会话挂回同一分身。
|
|
901
|
+
[ADAPTER_OP.FORK_SESSION, async (payload) => {
|
|
902
|
+
const sessions = safeGet('sessions')
|
|
903
|
+
if (!sessions || typeof sessions.fork !== 'function') {
|
|
904
|
+
return { ok: false, code: 'sessions-service-unavailable', error: 'sessions.fork unavailable in this instance' }
|
|
905
|
+
}
|
|
906
|
+
const sourceSessionId = String(payload?.sessionId ?? '').trim()
|
|
907
|
+
if (!sourceSessionId) return { ok: false, code: 'bad-session-id' }
|
|
908
|
+
const boundary = payload?.boundary
|
|
909
|
+
const childSessionId = String(payload?.childSessionId ?? '').trim()
|
|
910
|
+
try {
|
|
911
|
+
const child = childSessionId
|
|
912
|
+
? await sessions.fork(sourceSessionId, boundary, childSessionId)
|
|
913
|
+
: await sessions.fork(sourceSessionId, boundary)
|
|
914
|
+
const id = typeof child === 'string' ? child : (child?.id ?? child?.sessionId ?? childSessionId ?? null)
|
|
915
|
+
appendAudit('fork-session', { sourceSessionId, childSessionId: id, boundary: boundary ?? null })
|
|
916
|
+
return { ok: true, sourceSessionId, childSessionId: id }
|
|
917
|
+
} catch (error) {
|
|
918
|
+
return { ok: false, code: 'fork-failed', error: String(error?.message ?? error) }
|
|
919
|
+
}
|
|
920
|
+
}],
|
|
921
|
+
])
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* 外部插件注册的命令(v2.6.8)。与内置 HANDLERS 走同一条分派链:
|
|
925
|
+
* registerCommand(op, (payload, ctx) => result | {status:'accepted', trackingId})
|
|
926
|
+
* - 返回普通对象 → 同步成功(HTTP 200);
|
|
927
|
+
* - 返回 {status:'accepted', trackingId} → HTTP 202,executor 自行异步执行,
|
|
928
|
+
* 完成后必须通过 ctx.emit({kind:'command-done', trackingId, result}) 推送;
|
|
929
|
+
* - 返回 {status:'completed'|'rejected'} → 按对应形状返回;
|
|
930
|
+
* - 抛错 → handler-threw(fail-closed)。
|
|
931
|
+
* 内置 op 不允许被覆盖(避免协议语义被第三方悄悄替换)。
|
|
932
|
+
*/
|
|
933
|
+
const REGISTERED = new Map()
|
|
934
|
+
|
|
935
|
+
const registerCommand = (op, executor) => {
|
|
936
|
+
const kind = String(op ?? '').trim()
|
|
937
|
+
if (!kind) throw new TypeError('registerCommand requires a non-empty op')
|
|
938
|
+
if (typeof executor !== 'function') throw new TypeError('registerCommand requires a function')
|
|
939
|
+
if (HANDLERS.has(kind)) throw new Error(`cannot override built-in op: ${kind}`)
|
|
940
|
+
REGISTERED.set(kind, executor)
|
|
941
|
+
emit('fang/adapter-command-registered', { op: kind })
|
|
942
|
+
return () => {
|
|
943
|
+
if (REGISTERED.get(kind) === executor) {
|
|
944
|
+
REGISTERED.delete(kind)
|
|
945
|
+
emit('fang/adapter-command-unregistered', { op: kind })
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** 给命令套一个硬超时:handler 悬挂时也返回 timeout,而不是让 HTTP 挂着。 */
|
|
951
|
+
const withTimeout = (promise, timeoutMs) => {
|
|
952
|
+
if (!(timeoutMs > 0)) return Promise.resolve(promise)
|
|
953
|
+
return new Promise((resolve) => {
|
|
954
|
+
let settled = false
|
|
955
|
+
const timer = setTimeout(() => {
|
|
956
|
+
if (settled) return
|
|
957
|
+
settled = true
|
|
958
|
+
resolve({ __timeout: true })
|
|
959
|
+
}, timeoutMs)
|
|
960
|
+
Promise.resolve(promise).then(
|
|
961
|
+
(value) => {
|
|
962
|
+
if (settled) return
|
|
963
|
+
settled = true
|
|
964
|
+
clearTimeout(timer)
|
|
965
|
+
resolve(value)
|
|
966
|
+
},
|
|
967
|
+
(err) => {
|
|
968
|
+
if (settled) return
|
|
969
|
+
settled = true
|
|
970
|
+
clearTimeout(timer)
|
|
971
|
+
resolve({ ok: false, code: 'handler-threw', error: msgOf(err) })
|
|
972
|
+
},
|
|
973
|
+
)
|
|
974
|
+
})
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function command(input = {}) {
|
|
978
|
+
const request = input && typeof input === 'object' ? input : {}
|
|
979
|
+
const op = String(request.op ?? '').trim()
|
|
980
|
+
const payload = request.payload && typeof request.payload === 'object' ? request.payload : {}
|
|
981
|
+
if (!op) {
|
|
982
|
+
const envelope = { ok: false, op: '', error: 'missing-op', code: 'bad-request' }
|
|
983
|
+
emit('fang/adapter-command', { op: '', ok: false, code: envelope.code })
|
|
984
|
+
return envelope
|
|
985
|
+
}
|
|
986
|
+
const builtin = HANDLERS.get(op)
|
|
987
|
+
const registered = builtin ? null : REGISTERED.get(op)
|
|
988
|
+
if (!builtin && !registered) {
|
|
989
|
+
const envelope = { ok: false, op, error: `unknown-op:${op}`, code: 'unknown-op' }
|
|
990
|
+
emit('fang/adapter-command', { op, ok: false, code: envelope.code })
|
|
991
|
+
return envelope
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
let raw
|
|
995
|
+
try {
|
|
996
|
+
if (registered) {
|
|
997
|
+
// 外部注册命令:executor 自己决定同步/异步,拿到 commandId/trackingId/emit 上下文。
|
|
998
|
+
const execCtx = {
|
|
999
|
+
commandId: String(request.commandId ?? '') || null,
|
|
1000
|
+
trackingId: String(request.trackingId ?? '') || (request.commandId ? `track_${String(request.commandId)}` : newId('cmd')),
|
|
1001
|
+
emit: (event) => publish(event),
|
|
1002
|
+
logger: {
|
|
1003
|
+
debug: (...args) => { try { ctx.logger?.debug?.(...args) } catch { /* ignore */ } },
|
|
1004
|
+
warn: (...args) => { try { ctx.logger?.warn?.(...args) } catch { /* ignore */ } },
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
raw = await withTimeout(registered(payload, execCtx), commandTimeoutMs)
|
|
1008
|
+
if (raw && typeof raw === 'object' && raw.status === 'accepted') {
|
|
1009
|
+
const trackingId = String(raw.trackingId ?? execCtx.trackingId)
|
|
1010
|
+
const envelope = { ok: true, op, status: 'accepted', trackingId, commandId: execCtx.commandId, async: true }
|
|
1011
|
+
emit('fang/adapter-command', { op, ok: true, async: true, trackingId })
|
|
1012
|
+
return envelope
|
|
1013
|
+
}
|
|
1014
|
+
if (raw && typeof raw === 'object' && raw.status === 'completed') {
|
|
1015
|
+
raw = { ok: true, status: 'completed', result: raw.result === undefined ? null : raw.result }
|
|
1016
|
+
} else if (raw && typeof raw === 'object' && raw.status === 'rejected') {
|
|
1017
|
+
raw = { ok: false, code: String(raw.code ?? 'rejected'), error: String(raw.message ?? 'rejected') }
|
|
1018
|
+
}
|
|
1019
|
+
} else {
|
|
1020
|
+
raw = await withTimeout(builtin(payload), commandTimeoutMs)
|
|
1021
|
+
}
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
raw = { ok: false, code: 'handler-threw', error: msgOf(e) }
|
|
1024
|
+
}
|
|
1025
|
+
if (raw && typeof raw === 'object' && raw.__timeout === true) {
|
|
1026
|
+
const envelope = { ok: false, op, error: 'command-timeout', code: 'timeout' }
|
|
1027
|
+
emit('fang/adapter-command', { op, ok: false, code: envelope.code })
|
|
1028
|
+
return envelope
|
|
1029
|
+
}
|
|
1030
|
+
if (registered && raw && typeof raw === 'object' && raw.status === 'completed') {
|
|
1031
|
+
// 外部注册命令的 CommandResult 形状:保序返回 status/result,而不是塞进 result 字段。
|
|
1032
|
+
const envelope = { ok: true, op, status: 'completed', result: raw.result === undefined ? null : raw.result, commandId: String(request.commandId ?? '') || null }
|
|
1033
|
+
emit('fang/adapter-command', { op, ok: true })
|
|
1034
|
+
return envelope
|
|
1035
|
+
}
|
|
1036
|
+
if (raw && typeof raw === 'object' && raw.ok === false) {
|
|
1037
|
+
const envelope = {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
op,
|
|
1040
|
+
error: String(raw.error ?? raw.reason ?? 'command-failed'),
|
|
1041
|
+
code: String(raw.code ?? 'conflict'),
|
|
1042
|
+
result: raw,
|
|
1043
|
+
}
|
|
1044
|
+
emit('fang/adapter-command', { op, ok: false, code: envelope.code })
|
|
1045
|
+
return envelope
|
|
1046
|
+
}
|
|
1047
|
+
emit('fang/adapter-command', { op, ok: true, taskId })
|
|
1048
|
+
return { ok: true, op, result: raw === undefined ? null : raw }
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* v3 命令入口:commandId 幂等 + async 202。
|
|
1053
|
+
* 幂等由 `CommandCache.run()` 保证:
|
|
1054
|
+
* * completed 命中 → 直接重放第一次的响应(不重复执行副作用);
|
|
1055
|
+
* * running 命中(同一 commandId 并发提交)→ 等待同一个 promise,不会执行两次;
|
|
1056
|
+
* * 未命中 → 执行并把结果落缓存;fn 抛错(传输/编程错误)不缓存,允许重试。
|
|
1057
|
+
* async 语义:立刻返回 {status:'accepted', trackingId},进度/结果走 SSE
|
|
1058
|
+
* 的 command-done 事件;accepted 本身也进缓存,重复提交返回同一个 trackingId。
|
|
1059
|
+
*/
|
|
1060
|
+
async function commands(input = {}) {
|
|
1061
|
+
const normalized = normalizeCommandEnvelope(input)
|
|
1062
|
+
if (!normalized.ok) {
|
|
1063
|
+
return { ok: false, code: normalized.code, error: normalized.error, commandId: normalized.commandId ?? '' }
|
|
1064
|
+
}
|
|
1065
|
+
const { commandId, op, payload, async: isAsync } = normalized
|
|
1066
|
+
// 外部注册命令由 executor 自己决定同步/异步;通用 async 标志只作用于内置 op。
|
|
1067
|
+
const isRegistered = REGISTERED.has(op)
|
|
1068
|
+
|
|
1069
|
+
// 无 commandId = 不幂等(兼容 v2 用法),直接执行。
|
|
1070
|
+
if (!commandId) {
|
|
1071
|
+
if (isAsync && !isRegistered) {
|
|
1072
|
+
const trackingId = newId('cmd')
|
|
1073
|
+
void Promise.resolve()
|
|
1074
|
+
.then(() => command({ op, payload }))
|
|
1075
|
+
.then((result) => publish({ kind: ADAPTER_EVENT.COMMAND_DONE, trackingId, op, result }))
|
|
1076
|
+
.catch((e) => publish({ kind: ADAPTER_EVENT.ERROR, code: 'handler-threw', message: msgOf(e), trackingId, op }))
|
|
1077
|
+
return { ok: true, status: 'accepted', trackingId, op, commandId: null, async: true }
|
|
1078
|
+
}
|
|
1079
|
+
const result = await command({ op, payload })
|
|
1080
|
+
if (result && result.status === 'accepted') return { ...result, commandId: null }
|
|
1081
|
+
publish({ kind: ADAPTER_EVENT.COMMAND, op, ok: result.ok !== false, code: result.code ?? null, commandId: null })
|
|
1082
|
+
return { ...result, commandId: null }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let executed = false
|
|
1086
|
+
const envelope = await commandCache.run(commandId, async () => {
|
|
1087
|
+
executed = true
|
|
1088
|
+
if (isAsync && !isRegistered) {
|
|
1089
|
+
return { ok: true, status: 'accepted', trackingId: commandId, op, commandId, async: true }
|
|
1090
|
+
}
|
|
1091
|
+
const result = await command({ op, payload, commandId })
|
|
1092
|
+
return { ...result, commandId }
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
if (executed) {
|
|
1096
|
+
if (envelope && envelope.status === 'accepted') {
|
|
1097
|
+
// 外部注册命令的 accepted:executor 负责异步执行并通过 ctx.emit 推 command-done,
|
|
1098
|
+
// 这里不再二次调度(否则会执行两遍)。
|
|
1099
|
+
return envelope
|
|
1100
|
+
}
|
|
1101
|
+
if (isAsync && !isRegistered) {
|
|
1102
|
+
// 内置异步命令:结果通过事件返回;缓存里保留 accepted(重放仍是同一个 trackingId)。
|
|
1103
|
+
void Promise.resolve()
|
|
1104
|
+
.then(() => command({ op, payload }))
|
|
1105
|
+
.then((result) => publish({ kind: ADAPTER_EVENT.COMMAND_DONE, trackingId: commandId, op, result }))
|
|
1106
|
+
.catch((e) => publish({ kind: ADAPTER_EVENT.ERROR, code: 'handler-threw', message: msgOf(e), trackingId: commandId, op }))
|
|
1107
|
+
} else {
|
|
1108
|
+
publish({ kind: ADAPTER_EVENT.COMMAND, op, ok: envelope.ok !== false, code: envelope.code ?? null, commandId })
|
|
1109
|
+
}
|
|
1110
|
+
return envelope
|
|
1111
|
+
}
|
|
1112
|
+
return { ...envelope, deduplicated: true }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const service = {
|
|
1116
|
+
role,
|
|
1117
|
+
taskId,
|
|
1118
|
+
version: FANG_VERSION,
|
|
1119
|
+
protocol: ADAPTER_PROTOCOL_VERSION,
|
|
1120
|
+
sessionId,
|
|
1121
|
+
/** 是否启用了 v3 Bearer 认证(false = v2 兼容模式)。 */
|
|
1122
|
+
authEnabled: expectedTokenBuf !== null,
|
|
1123
|
+
/** 已注册的 HTTP 路径(测试用;无 webServer 时为空数组)。 */
|
|
1124
|
+
routes: [],
|
|
1125
|
+
|
|
1126
|
+
hello,
|
|
1127
|
+
identity,
|
|
1128
|
+
reportClient,
|
|
1129
|
+
status,
|
|
1130
|
+
approvals,
|
|
1131
|
+
audit,
|
|
1132
|
+
command,
|
|
1133
|
+
commands,
|
|
1134
|
+
registerCommand,
|
|
1135
|
+
registeredCommands: () => [...REGISTERED.keys()],
|
|
1136
|
+
versionInfo,
|
|
1137
|
+
health,
|
|
1138
|
+
state,
|
|
1139
|
+
eventsSince,
|
|
1140
|
+
lastCursor: () => eventLog.lastCursor,
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
ctx.effect(() => ctx.provide(SERVICE.adapter, service))
|
|
1144
|
+
|
|
1145
|
+
// 插件卸载:关掉所有 SSE 长连接,清空订阅与幂等缓存(不留悬挂定时器/引用)。
|
|
1146
|
+
ctx.effect(() => () => {
|
|
1147
|
+
for (const close of [...activeStreams]) {
|
|
1148
|
+
try {
|
|
1149
|
+
close()
|
|
1150
|
+
} catch {
|
|
1151
|
+
/* ignore */
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
subscribers.clear()
|
|
1155
|
+
commandCache.clear()
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
// ── HTTP 路由(处理器与 service 共用上面的实现)────────────────────────────
|
|
1159
|
+
const json = (res, code, obj) => {
|
|
1160
|
+
try {
|
|
1161
|
+
if (!res.headersSent) {
|
|
1162
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1163
|
+
}
|
|
1164
|
+
} catch {
|
|
1165
|
+
/* 假 res / 已写头的 res 都不能把处理器打挂 */
|
|
1166
|
+
}
|
|
1167
|
+
try {
|
|
1168
|
+
if (!res.writableEnded) res.end(JSON.stringify(obj))
|
|
1169
|
+
} catch {
|
|
1170
|
+
/* 同上 */
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** 错误码 → HTTP 状态码:唯一来源是 kernel 的 ADAPTER_ERROR_STATUS(PROTOCOL-V3 §8)。 */
|
|
1175
|
+
const statusForCode = (code) => statusForAdapterError(code)
|
|
1176
|
+
|
|
1177
|
+
/** 读取 JSON 请求体;不抛,返回 {body} 或 {error}。 */
|
|
1178
|
+
const readJsonBody = (req) => new Promise((resolve) => {
|
|
1179
|
+
let raw = ''
|
|
1180
|
+
let settled = false
|
|
1181
|
+
const finish = (value) => {
|
|
1182
|
+
if (settled) return
|
|
1183
|
+
settled = true
|
|
1184
|
+
resolve(value)
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
req.on('data', (chunk) => {
|
|
1188
|
+
if (settled) return
|
|
1189
|
+
raw += chunk
|
|
1190
|
+
if (raw.length > 1_000_000) {
|
|
1191
|
+
finish({ error: 'body-too-large' })
|
|
1192
|
+
try {
|
|
1193
|
+
req.destroy()
|
|
1194
|
+
} catch {
|
|
1195
|
+
/* ignore */
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
})
|
|
1199
|
+
req.on('end', () => {
|
|
1200
|
+
if (settled) return
|
|
1201
|
+
if (!raw.trim()) return finish({ body: {} })
|
|
1202
|
+
try {
|
|
1203
|
+
finish({ body: JSON.parse(raw) })
|
|
1204
|
+
} catch {
|
|
1205
|
+
finish({ error: 'invalid-json' })
|
|
1206
|
+
}
|
|
1207
|
+
})
|
|
1208
|
+
req.on('error', () => finish({ error: 'request-error' }))
|
|
1209
|
+
} catch (e) {
|
|
1210
|
+
finish({ error: msgOf(e) })
|
|
1211
|
+
}
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
/** 从原始 req.url 解析 ?limit=(测试里 req.url 可能是 undefined,缺省走 100)。 */
|
|
1215
|
+
const parseLimit = (rawUrl) => {
|
|
1216
|
+
const s = String(rawUrl ?? '')
|
|
1217
|
+
const m = /[?&]limit=([^&#]+)/.exec(s)
|
|
1218
|
+
if (!m) return undefined
|
|
1219
|
+
const n = Math.floor(Number(decodeURIComponentSafe(m[1])))
|
|
1220
|
+
return Number.isFinite(n) ? n : undefined
|
|
1221
|
+
}
|
|
1222
|
+
const decodeURIComponentSafe = (value) => {
|
|
1223
|
+
try {
|
|
1224
|
+
return decodeURIComponent(value)
|
|
1225
|
+
} catch {
|
|
1226
|
+
return value
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** 极简 query 解析(不依赖 URL 解析器,req.url 在测试里可能是 undefined)。 */
|
|
1231
|
+
const parseQuery = (rawUrl) => {
|
|
1232
|
+
const out = {}
|
|
1233
|
+
const text = String(rawUrl ?? '')
|
|
1234
|
+
const at = text.indexOf('?')
|
|
1235
|
+
if (at < 0) return out
|
|
1236
|
+
for (const pair of text.slice(at + 1).split('&')) {
|
|
1237
|
+
if (!pair) continue
|
|
1238
|
+
const eq = pair.indexOf('=')
|
|
1239
|
+
const key = decodeURIComponentSafe(eq < 0 ? pair : pair.slice(0, eq))
|
|
1240
|
+
const value = eq < 0 ? '' : decodeURIComponentSafe(pair.slice(eq + 1))
|
|
1241
|
+
if (!(key in out)) out[key] = value
|
|
1242
|
+
}
|
|
1243
|
+
return out
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const registerRoutes = (webServer) => {
|
|
1247
|
+
// v3.1.14:浏览器无法持有 adapter Bearer token;身份探测必须是公开只读路由。
|
|
1248
|
+
// 服务端仍只监听 loopback,且返回值不含 taskId/token(见 identity())。
|
|
1249
|
+
webServer.register({
|
|
1250
|
+
kind: 'exact',
|
|
1251
|
+
path: `${prefix}${ADAPTER_ROUTE.IDENTITY}`,
|
|
1252
|
+
method: 'GET',
|
|
1253
|
+
handler: (req, res) => json(res, 200, identity()),
|
|
1254
|
+
})
|
|
1255
|
+
try { service.routes.push(`${prefix}${ADAPTER_ROUTE.IDENTITY}`) } catch { /* fake ws */ }
|
|
1256
|
+
// v3.1.17:客户端自报加载状态(只记录时间戳,不含身份/token)。
|
|
1257
|
+
webServer.register({
|
|
1258
|
+
kind: 'exact',
|
|
1259
|
+
path: `${prefix}${ADAPTER_ROUTE.CLIENT_REPORT}`,
|
|
1260
|
+
method: 'POST',
|
|
1261
|
+
handler: (req, res) => json(res, 200, reportClient()),
|
|
1262
|
+
})
|
|
1263
|
+
try { service.routes.push(`${prefix}${ADAPTER_ROUTE.CLIENT_REPORT}`) } catch { /* fake ws */ }
|
|
1264
|
+
const route = (method, routePath, handler) => {
|
|
1265
|
+
webServer.register({
|
|
1266
|
+
kind: 'exact',
|
|
1267
|
+
path: routePath,
|
|
1268
|
+
method,
|
|
1269
|
+
handler: async (req, res) => {
|
|
1270
|
+
const actual = String(req?.method ?? 'GET').toUpperCase()
|
|
1271
|
+
// Same perimeter as /fang/api/*: loopback Host, Origin/Sec-Fetch-Site,
|
|
1272
|
+
// adapter Bearer token, JSON-only writes. Auth before method (401 not 405).
|
|
1273
|
+
const decision = checkRequestAuth(req, {
|
|
1274
|
+
token: adapterToken,
|
|
1275
|
+
// v2 compatibility (explicit allowLegacyNoToken) is the only case where
|
|
1276
|
+
// the token comparison is skipped; Host/Origin/CSRF still apply.
|
|
1277
|
+
publicRoute: adapterToken.length === 0 && legacyAllowed,
|
|
1278
|
+
})
|
|
1279
|
+
if (!decision.ok) {
|
|
1280
|
+
return json(res, decision.status, { ...authErrorBody(decision), path: routePath })
|
|
1281
|
+
}
|
|
1282
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(actual)) {
|
|
1283
|
+
const contentType = String(req?.headers?.['content-type'] ?? '').toLowerCase()
|
|
1284
|
+
if (contentType && !contentType.includes('application/json')) {
|
|
1285
|
+
return json(res, 415, { ok: false, code: 'content-type-required', error: 'content-type-required', path: routePath })
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (!authorized(req)) {
|
|
1289
|
+
return json(res, 401, { ok: false, code: 'unauthorized', error: 'unauthorized', path: routePath })
|
|
1290
|
+
}
|
|
1291
|
+
if (actual !== method) {
|
|
1292
|
+
try { res.setHeader?.('allow', method) } catch { /* fake res */ }
|
|
1293
|
+
return json(res, 405, { ok: false, code: 'method-not-allowed', error: 'method-not-allowed', path: routePath })
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
return await handler(req, res)
|
|
1297
|
+
} catch (e) {
|
|
1298
|
+
if (res?.headersSent) {
|
|
1299
|
+
try {
|
|
1300
|
+
res.end()
|
|
1301
|
+
} catch {
|
|
1302
|
+
/* ignore */
|
|
1303
|
+
}
|
|
1304
|
+
return undefined
|
|
1305
|
+
}
|
|
1306
|
+
return json(res, 500, { ok: false, code: 'handler-threw', error: msgOf(e) })
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
})
|
|
1310
|
+
service.routes.push(routePath)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const sendResult = (res, out) => json(res, out && out.ok !== false ? 200 : statusForCode(out?.code), out)
|
|
1314
|
+
|
|
1315
|
+
// ── v2 五条(PROTOCOL-V2 §3,保留)─────────────────────────────────────
|
|
1316
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.HELLO}`, (req, res) => json(res, 200, hello()))
|
|
1317
|
+
|
|
1318
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.STATUS}`, (req, res) => json(res, 200, status()))
|
|
1319
|
+
|
|
1320
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.APPROVALS}`, (req, res) => sendResult(res, approvals()))
|
|
1321
|
+
|
|
1322
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.AUDIT}`, (req, res) => {
|
|
1323
|
+
const limit = parseLimit(req?.url)
|
|
1324
|
+
return sendResult(res, audit(limit === undefined ? {} : { limit }))
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
route('POST', `${prefix}${ADAPTER_ROUTE.COMMAND}`, async (req, res) => {
|
|
1328
|
+
const parsed = await readJsonBody(req)
|
|
1329
|
+
if (parsed.error) {
|
|
1330
|
+
const code = parsed.error === 'body-too-large' ? 'body-too-large' : 'bad-request'
|
|
1331
|
+
return json(res, statusForCode(code), { ok: false, code, error: parsed.error })
|
|
1332
|
+
}
|
|
1333
|
+
const out = await command(parsed.body ?? {})
|
|
1334
|
+
return sendResult(res, out)
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1337
|
+
// ── v3 五条(PROTOCOL-V3 §4)──────────────────────────────────────────
|
|
1338
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.VERSION}`, (req, res) => json(res, 200, versionInfo()))
|
|
1339
|
+
|
|
1340
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.HEALTH}`, (req, res) => json(res, 200, health()))
|
|
1341
|
+
|
|
1342
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.STATE}`, (req, res) => json(res, 200, state()))
|
|
1343
|
+
|
|
1344
|
+
route('POST', `${prefix}${ADAPTER_ROUTE.COMMANDS}`, async (req, res) => {
|
|
1345
|
+
const parsed = await readJsonBody(req)
|
|
1346
|
+
if (parsed.error) {
|
|
1347
|
+
const code = parsed.error === 'body-too-large' ? 'body-too-large' : 'bad-request'
|
|
1348
|
+
return json(res, statusForCode(code), { ok: false, code, error: parsed.error })
|
|
1349
|
+
}
|
|
1350
|
+
const out = await commands(parsed.body ?? {})
|
|
1351
|
+
if (out && out.ok === false) return json(res, statusForCode(out.code), out)
|
|
1352
|
+
if (out && out.status === 'accepted') return json(res, 202, out)
|
|
1353
|
+
return json(res, 200, out)
|
|
1354
|
+
})
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* SSE 长连接:鉴权由 route 包装器统一处理;session 不匹配 = 409,cursor 过期 = 410。
|
|
1358
|
+
* 先回放环形缓冲,再挂订阅;心跳注释帧每 heartbeatMs 一次(同时驱动审批 diff)。
|
|
1359
|
+
*/
|
|
1360
|
+
route('GET', `${prefix}${ADAPTER_ROUTE.EVENTS}`, async (req, res) => {
|
|
1361
|
+
const query = parseQuery(req?.url)
|
|
1362
|
+
const since = query.since
|
|
1363
|
+
const session = query.session
|
|
1364
|
+
if (session && session !== sessionId) {
|
|
1365
|
+
return json(res, 409, { ok: false, code: 'session-changed', error: 'session-changed', sessionId })
|
|
1366
|
+
}
|
|
1367
|
+
syncApprovals()
|
|
1368
|
+
const replay = eventsSince(since, session)
|
|
1369
|
+
if (replay.ok === false) return json(res, statusForCode(replay.code), replay)
|
|
1370
|
+
if (replay.gap) {
|
|
1371
|
+
return json(res, 410, {
|
|
1372
|
+
ok: false,
|
|
1373
|
+
code: 'cursor-expired',
|
|
1374
|
+
error: 'cursor-expired',
|
|
1375
|
+
sessionId,
|
|
1376
|
+
lastCursor: replay.lastCursor,
|
|
1377
|
+
nextCursor: replay.nextCursor,
|
|
1378
|
+
})
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
try {
|
|
1382
|
+
res.writeHead(200, {
|
|
1383
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1384
|
+
'cache-control': 'no-store, no-transform',
|
|
1385
|
+
connection: 'keep-alive',
|
|
1386
|
+
'x-accel-buffering': 'no',
|
|
1387
|
+
})
|
|
1388
|
+
} catch {
|
|
1389
|
+
/* 假 res 的 writeHead 也能工作;真实 res 这里不应抛 */
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const write = (chunk) => {
|
|
1393
|
+
try {
|
|
1394
|
+
if (!res.writableEnded) res.write(chunk)
|
|
1395
|
+
} catch {
|
|
1396
|
+
/* 连接已断,等 close 回调清理 */
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
write(SSE_RETRY_FRAME)
|
|
1401
|
+
write(sseComment('connected'))
|
|
1402
|
+
for (const envelope of replay.events) write(sseFrame(envelope))
|
|
1403
|
+
|
|
1404
|
+
const off = subscribe((envelope) => write(sseFrame(envelope)))
|
|
1405
|
+
const heartbeat = setInterval(() => {
|
|
1406
|
+
syncApprovals()
|
|
1407
|
+
write(sseComment('heartbeat'))
|
|
1408
|
+
}, heartbeatMs)
|
|
1409
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref()
|
|
1410
|
+
|
|
1411
|
+
let closed = false
|
|
1412
|
+
const close = () => {
|
|
1413
|
+
if (closed) return
|
|
1414
|
+
closed = true
|
|
1415
|
+
clearInterval(heartbeat)
|
|
1416
|
+
off()
|
|
1417
|
+
activeStreams.delete(close)
|
|
1418
|
+
try {
|
|
1419
|
+
if (!res.writableEnded) res.end()
|
|
1420
|
+
} catch {
|
|
1421
|
+
/* ignore */
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
activeStreams.add(close)
|
|
1425
|
+
try {
|
|
1426
|
+
req?.on?.('close', close)
|
|
1427
|
+
req?.on?.('aborted', close)
|
|
1428
|
+
res?.on?.('close', close)
|
|
1429
|
+
res?.on?.('error', close)
|
|
1430
|
+
} catch {
|
|
1431
|
+
/* ignore */
|
|
1432
|
+
}
|
|
1433
|
+
return undefined
|
|
1434
|
+
})
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
try {
|
|
1438
|
+
if (typeof ctx.inject === 'function') {
|
|
1439
|
+
ctx.inject(['webServer'], (child) => {
|
|
1440
|
+
let webServer = null
|
|
1441
|
+
try {
|
|
1442
|
+
webServer = child?.get?.('webServer') ?? child?.webServer ?? null
|
|
1443
|
+
} catch {
|
|
1444
|
+
webServer = null
|
|
1445
|
+
}
|
|
1446
|
+
if (!webServer || typeof webServer.register !== 'function') return
|
|
1447
|
+
registerRoutes(webServer)
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
} catch {
|
|
1451
|
+
/* webServer 不可用只意味着没有 HTTP 控制面,service 仍可被进程内调用 */
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return service
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
export const _internals = {
|
|
1458
|
+
DEFAULTS,
|
|
1459
|
+
TASK_ID_ENVS,
|
|
1460
|
+
MAX_FILE_BYTES,
|
|
1461
|
+
MAX_TOTAL_BYTES,
|
|
1462
|
+
ADAPTER_PROTOCOL_VERSION,
|
|
1463
|
+
ADAPTER_PROTOCOL_MIN,
|
|
1464
|
+
resolveTaskId,
|
|
1465
|
+
readPluginTree,
|
|
1466
|
+
normalizePrefix,
|
|
1467
|
+
resolveInside,
|
|
1468
|
+
}
|