@bd7pil/reeve 0.1.3 → 0.1.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/src/proxy.js DELETED
@@ -1,587 +0,0 @@
1
- // 代理层的"自持"实现 —— 纯插件形态:**不需要任何外部脚本、不需要 ocrc**。
2
- //
3
- // 插件在引擎进程里做五件事(顺序即启动顺序):
4
- // ① 取件:按 pin 的 URL + sha256 把 Bifrost 二进制放到自己的目录(首次一次;失败吵)
5
- // ② 派生 Bifrost 的 config.json:链来自 chainPath(默认 ~/.config/opencode/reeve.jsonc,
6
- // 缺省用插件自带的 default-chain.json),**并在这里关掉回环鉴权**(见下"凭据")
7
- // ③ 起进程:spawn + 日志落文件;已有健康实例则复用(reuse-if-healthy)
8
- // ④ 等就绪:轮询 /api/version(有上限,别把引擎启动拖死)
9
- // ⑤ 守护:一个 unref 的定时器,每 N 秒探活;死了就地拉起
10
- //
11
- // **凭据**:引擎的 `apiKey` 在引擎启动时就定了,插件改不了它的环境变量 —— 所以自持形态下
12
- // 把 Bifrost 的 `enforce_auth_on_inference` 关掉、引擎侧用占位 apiKey(回环地址上的单用户机器,
13
- // 这个取舍是划算的;要收紧就回到 virtual key 形态:由启动器注入 REEVE_VK)。
14
- // provider 的真 key 只进 Bifrost 的进程环境,来源是 opencode 自己的 `auth.json`(单一真相)。
15
- //
16
- // 实测依据(EL7 staging,引擎 v2.0.27):插件在引擎里 spawn 长命子进程、process.kill 判活、
17
- // fetch 打 127.0.0.1 的 HTTP 全部可用(探针见 scripts/probe-runtime/)。
18
-
19
- import { spawn } from 'node:child_process'
20
- import { createHash, randomBytes } from 'node:crypto'
21
- import { chmod, mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
22
- import { homedir } from 'node:os'
23
- import { dirname, join } from 'node:path'
24
- import { fileURLToPath } from 'node:url'
25
-
26
- import { defaultChainPath, parseChainText, readChainFile, resolveChain, rulesDrift, rulesSame } from './chain.js'
27
-
28
- /** 插件自带的默认链(与仓库 proxy/config.json 同源;单测里有一条断言盯着它俩不漂移)。 */
29
- export const BUNDLED_CHAIN_PATH = fileURLToPath(new URL('../default-chain.json', import.meta.url))
30
-
31
- /** pin 住的那一份(与 ocrc 的 pin 一致;实测版本化 URL 与已部署二进制的 sha256 逐字节相同)。 */
32
- export const BIFROST_PIN = Object.freeze({
33
- version: '2.3.0',
34
- url: 'https://downloads.getmaxim.ai/bifrost/v2.3.0/linux/amd64/bifrost-http',
35
- sha256: 'e62a6425218d037e2aea10446f1f4ad1979291902623575a04b7e0ed46886f92',
36
- size: 134_264_480,
37
- binary: 'bifrost-http',
38
- })
39
-
40
- export const PROXY_DEFAULTS = Object.freeze({
41
- port: 8080,
42
- host: '127.0.0.1',
43
- healthPath: '/api/version',
44
- /** 起完等就绪的上限。别太大 —— 它挡在引擎启动路径上 */
45
- readyTimeoutMs: 20_000,
46
- /** 守护探活的间隔;0 = 不守护 */
47
- superviseMs: 20_000,
48
- /** 盯链文件(`~/.reeve/chain.json`)的间隔:**只做一次 stat**,变了才重新派生 + 收敛 */
49
- chainWatchMs: 5_000,
50
- })
51
-
52
- export function proxyPaths({ home = homedir(), dir = null } = {}) {
53
- const root = dir ?? join(home, '.reeve', 'proxy')
54
- return {
55
- root,
56
- binary: join(root, BIFROST_PIN.binary),
57
- config: join(root, 'config.json'),
58
- token: join(root, 'setup-token'),
59
- log: join(root, 'bifrost.log'),
60
- }
61
- }
62
-
63
- // ── 纯函数(可测的部分) ────────────────────────────────────────────────────
64
-
65
- /**
66
- * provider id → 环境变量名。约定:`REEVE_KEY_<大写、非字母数字→下划线>`。
67
- * 链配置里 `keys[].value` 写 `env.<这个名字>`,插件**只把 auth.json 里真有的**注进去。
68
- */
69
- export function providerEnvName(providerId) {
70
- return 'REEVE_KEY_' + String(providerId).toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
71
- }
72
-
73
- /**
74
- * 从 opencode 的 auth.json + 链里声明的 provider 派生 Bifrost 的进程环境。
75
- * 只包含链里用到的;缺 key 的如实报出来(不静默)。
76
- *
77
- * 查 auth.json 用哪个 id:默认与链里的 provider id 相同;链里为避开 Bifrost 内置名改过名的
78
- * (例如 `deepseek` → `deepseek-api`),在 key 条目上写 `"fromAuth": "deepseek"`。
79
- *
80
- * @param {Record<string, {key?: string}>} auth auth.json 的内容
81
- * @param {Array<{id: string, keys?: Array<{value?: string, fromAuth?: string}>}>} chainProviders
82
- * @returns {{ env: Record<string,string>, missing: string[] }}
83
- */
84
- export function deriveProviderEnv(auth, chainProviders) {
85
- const env = {}
86
- const missing = []
87
- for (const p of chainProviders ?? []) {
88
- const id = p?.id
89
- if (!id) continue
90
- const envEntries = (p.keys ?? [])
91
- .filter((k) => typeof k?.value === 'string' && k.value.startsWith('env.'))
92
- .map((k) => ({ name: k.value.slice('env.'.length), fromAuth: k.fromAuth ?? id }))
93
- // 链里没写 keys[].value 的(少见)退回约定名 —— 但 provider 名字本身要知道
94
- const entries = envEntries.length > 0 ? envEntries : [{ name: providerEnvName(id), fromAuth: id }]
95
- let found = false
96
- for (const e of entries) {
97
- const key = auth?.[e.fromAuth]?.key
98
- if (typeof key === 'string' && key.length > 0) {
99
- env[e.name] = key
100
- found = true
101
- }
102
- }
103
- if (!found) missing.push(id)
104
- }
105
- return { env, missing }
106
- }
107
-
108
- /**
109
- * 派生要写给 Bifrost 的 config.json。
110
- * - 原样带上链(providers / governance.routing_rules / client 等)
111
- * - `noAuth`:关掉回环鉴权(自持形态的凭据取舍,见文件头)
112
- * - 去掉 `"//"` 前缀的注释键(Bifrost 不认,虽然实测它能容忍未知键,但没必要留)
113
- */
114
- export function buildProxyConfig(chain, { noAuth = true } = {}) {
115
- const clean = {}
116
- for (const [k, v] of Object.entries(chain ?? {})) {
117
- if (k.startsWith('//')) continue
118
- clean[k] = v
119
- }
120
- const client = { ...(clean.client ?? {}) }
121
- if (noAuth) client.enforce_auth_on_inference = false
122
- clean.client = client
123
- return clean
124
- }
125
-
126
- /** 二进制要不要重下:不存在 / 大小不符 / 校验和不对(后者只有读了才算,交给 verifyBinary)。 */
127
- export function needsDownload(fileStat, pin = BIFROST_PIN) {
128
- if (!fileStat || !fileStat.isFile?.()) return true
129
- return Number(fileStat.size) !== pin.size
130
- }
131
-
132
- /** spawn 的参数。单独拿出来是为了让测试能逐字断言。 */
133
- export function buildSpawnArgs({ paths, port = PROXY_DEFAULTS.port, host = PROXY_DEFAULTS.host }) {
134
- return [paths.binary, ['-port', String(port), '-host', host, '-app-dir', paths.root]]
135
- }
136
-
137
- /**
138
- * 端口上那个健康的代理**是不是我们自己的**(cmdline 里带我们的 app-dir)。
139
- *
140
- * 为什么必须有这一步:两种形态可能共用同一个端口 —— 由启动器起的代理(要 VK)
141
- * 与插件自持的代理(关回环鉴权)。插件若盲目"复用",会拿占位 key 去打一个要 VK 的代理,
142
- * 报 401 "virtual key is required"(实测踩到过)。所以:**只在确认是自己的时候复用**;
143
- * 是别人的就明确报出来(吵),也不去抢端口。
144
- *
145
- * Linux 上读 /proc;其它平台读不到就返回 'unknown'(退回"健康即复用"的旧行为)。
146
- * @returns {'ours' | 'foreign' | 'unknown'}
147
- */
148
- export async function ownerOfPort({ dir, readdirImpl = readdir, readFileImpl = readFile }) {
149
- let pids
150
- try {
151
- pids = await readdirImpl('/proc')
152
- } catch {
153
- return 'unknown'
154
- }
155
- const mine = String(dir)
156
- let sawBifrost = false
157
- for (const name of pids) {
158
- if (!/^\d+$/.test(name)) continue
159
- try {
160
- const cmdline = (await readFileImpl(`/proc/${name}/cmdline`, 'utf8')).split('\0').join(' ')
161
- if (!cmdline.includes('bifrost-http')) continue
162
- sawBifrost = true
163
- if (cmdline.includes(mine)) return 'ours'
164
- } catch {
165
- /* 进程没了 */
166
- }
167
- }
168
- return sawBifrost ? 'foreign' : 'unknown'
169
- }
170
-
171
- /** 从 `/api/version` 的响应体里判就绪(实测形如 `"v2.3.0"`)。 */
172
- export function isReady(status, body) {
173
- return status === 200 && typeof body === 'string' && body.length > 0
174
- }
175
-
176
- // ── IO 部分(薄) ──────────────────────────────────────────────────────────
177
-
178
- async function sha256File(path) {
179
- const buf = await readFile(path)
180
- return createHash('sha256').update(buf).digest('hex')
181
- }
182
-
183
- /** 幂等取件。返回 'ok' | 'installed';失败抛(**吵**,不静默)。 */
184
- export async function ensureBinary({ paths, pin = BIFROST_PIN, fetchImpl = fetch, logger }) {
185
- await mkdir(paths.root, { recursive: true })
186
- try {
187
- const st = await stat(paths.binary)
188
- if (!needsDownload(st, pin) && (await sha256File(paths.binary)) === pin.sha256) return 'ok'
189
- logger?.debug?.('[reeve] 代理二进制校验和不符,重下')
190
- } catch {
191
- /* 不存在 → 往下走 */
192
- }
193
- logger?.debug?.(`[reeve] 代理二进制取件中(${pin.size} B,${pin.version})…`)
194
- const res = await fetchImpl(pin.url)
195
- if (!res.ok) throw new Error(`取件失败:HTTP ${res.status}`)
196
- const buf = Buffer.from(await res.arrayBuffer())
197
- const got = createHash('sha256').update(buf).digest('hex')
198
- if (got !== pin.sha256) throw new Error(`取件失败:sha256 不符(得到 ${got.slice(0, 12)}…)`)
199
- const part = `${paths.binary}.part`
200
- await writeFile(part, buf)
201
- await chmod(part, 0o755)
202
- await rename(part, paths.binary)
203
- logger?.debug?.('[reeve] 代理二进制已就位(sha256 校验通过)')
204
- return 'installed'
205
- }
206
-
207
- async function readAuth(authPath) {
208
- try {
209
- return JSON.parse(await readFile(authPath, 'utf8'))
210
- } catch {
211
- return {}
212
- }
213
- }
214
-
215
- async function readChain(chainPath, bundledPath) {
216
- for (const p of [chainPath, bundledPath]) {
217
- if (!p) continue
218
- try {
219
- return { chain: parseChainText(await readFile(p, 'utf8')), from: p }
220
- } catch {
221
- /* 换下一个 */
222
- }
223
- }
224
- throw new Error('找不到链配置(chainPath 与插件自带的 default-chain.json 都读不到)')
225
- }
226
-
227
- async function readOrCreateToken(path) {
228
- try {
229
- const t = (await readFile(path, 'utf8')).trim()
230
- if (t.length > 0) return t
231
- } catch {
232
- /* 生成 */
233
- }
234
- const token = randomBytes(24).toString('hex')
235
- const fd = await open(path, 'w', 0o600)
236
- await fd.writeFile(token + '\n')
237
- await fd.close()
238
- return token
239
- }
240
-
241
- /**
242
- * 只读那份 setup token(插件里的统计要用它打 `/api/logs`)。
243
- * 单独导出而不是把 token 挂到代理的返回值上:**凭据不跟着状态对象到处走**。
244
- */
245
- export async function readSetupToken(path) {
246
- try {
247
- const t = (await readFile(path, 'utf8')).trim()
248
- return t.length > 0 ? t : null
249
- } catch {
250
- return null
251
- }
252
- }
253
-
254
- async function healthy({ port, host = PROXY_DEFAULTS.host, timeoutMs = 1500, fetchImpl = fetch }) {
255
- try {
256
- const ctl = AbortSignal.timeout(timeoutMs)
257
- const res = await fetchImpl(`http://${host}:${port}${PROXY_DEFAULTS.healthPath}`, { signal: ctl })
258
- return isReady(res.status, await res.text())
259
- } catch {
260
- return false
261
- }
262
- }
263
-
264
- /**
265
- * 把运行时的 routing_rules 收敛到文件(缺失→建、有差异→改、多余→删),**并复核**。
266
- *
267
- * 复核那一步不是装饰:POST/PUT 是"我们以为改成什么了",GET 一次才是"运行时现在是什么"。
268
- * 快照里的 drift 就来自复核(面板据此显示"已收敛 / 还差几条")。
269
- */
270
- async function convergeRules({ port, host, token, desired, logger }) {
271
- const base = `http://${host}:${port}/api/routing/rules`
272
- const hdr = { 'X-Bifrost-Setup-Token': token, 'Content-Type': 'application/json' }
273
- const SERVER = ['config_hash', 'created_at', 'updated_at', 'chain_rule']
274
- const clean = (r) => Object.fromEntries(Object.entries(r).filter(([k]) => !SERVER.includes(k)))
275
- const readRuntime = async () => {
276
- const res = await fetch(base, { headers: hdr })
277
- if (res.status !== 200) return null
278
- return (await res.json()).rules ?? []
279
- }
280
- try {
281
- const runtime = await readRuntime()
282
- if (runtime === null) return { changed: 0, drift: null, detail: '读不到运行时规则' }
283
- const byId = new Map(runtime.filter((r) => r?.id).map((r) => [r.id, r]))
284
- let changed = 0
285
- for (const rule of desired ?? []) {
286
- const have = byId.get(rule.id)
287
- if (!have) {
288
- await fetch(base, { method: 'POST', headers: hdr, body: JSON.stringify(clean(rule)) })
289
- changed++
290
- } else if (!rulesSame(have, rule)) {
291
- await fetch(`${base}/${rule.id}`, { method: 'PUT', headers: hdr, body: JSON.stringify(clean(rule)) })
292
- changed++
293
- }
294
- }
295
- const want = new Set((desired ?? []).map((r) => r?.id))
296
- for (const id of byId.keys()) {
297
- if (!want.has(id)) {
298
- await fetch(`${base}/${id}`, { method: 'DELETE', headers: hdr })
299
- changed++
300
- }
301
- }
302
- const after = await readRuntime()
303
- const drift = after === null ? null : rulesDrift(desired, after)
304
- if (changed > 0) logger?.debug?.(`[reeve] 收敛:把运行时规则对齐到文件(改/建/删 ${changed} 条)`)
305
- if (drift?.drifted) logger?.debug?.(`[reeve] 收敛后仍有差异:${JSON.stringify(drift)}`)
306
- return { changed, drift, detail: null }
307
- } catch (err) {
308
- const detail = String(err).slice(0, 120)
309
- logger?.debug?.(`[reeve] 收敛失败(忽略):${detail}`)
310
- return { changed: 0, drift: null, detail }
311
- }
312
- }
313
-
314
- /**
315
- * 自持代理:取件 → 派生配置 → 复用或拉起 → 等就绪 → 收敛 → 起守护。
316
- * 返回 { dispose, ready, detail, port };任何失败都**只记日志不抛** ——
317
- * 代理起不来时该让引擎照常跑(模型会报错,那是吵的),而不是把插件加载搞崩。
318
- *
319
- * ⚠️ **首次取件不挡引擎启动**:128 MB 的下载放在后台,`ready=false` 先返回;
320
- * 守护每 `superviseMs` 重试一次 bootstrap(取件是幂等的),装好就自动往下走。
321
- * 为什么必须这样:引擎启动被拖住超过 openchamber 的健康等待(45 s)会被判不健康。
322
- */
323
- export async function startOwnedProxy({
324
- opts = {},
325
- logger = console,
326
- bundledChainPath = BUNDLED_CHAIN_PATH,
327
- authPath = null,
328
- fetchImpl = fetch,
329
- spawnImpl = spawn,
330
- /** 状态一变就回调(快照/面板要跟上——尤其"后台取件完成"那一刻)。 */
331
- onState = null,
332
- } = {}) {
333
- const port = Number(opts.port ?? PROXY_DEFAULTS.port)
334
- const host = opts.host ?? PROXY_DEFAULTS.host
335
- const paths = proxyPaths({ dir: opts.dir ?? null })
336
- const readyTimeoutMs = Number(opts.readyTimeoutMs ?? PROXY_DEFAULTS.readyTimeoutMs)
337
- const superviseMs = Number(opts.superviseMs ?? PROXY_DEFAULTS.superviseMs)
338
- const chainWatchMs = Number(opts.chainWatchMs ?? PROXY_DEFAULTS.chainWatchMs)
339
- const readdirImpl = opts.readdirImpl ?? readdir
340
- const auth = authPath ?? opts.authPath ?? join(homedir(), '.local', 'share', 'opencode', 'auth.json')
341
- const chainPath = opts.chainPath ?? join(homedir(), '.config', 'opencode', 'reeve.jsonc')
342
- /** 只属于 REEVE 的那份小配置(面板写、插件读)。缺它就用链基座的规则 —— 回落现状。 */
343
- const chainFilePath = opts.chainFilePath ?? opts.tiersPath ?? defaultChainPath({ home: opts.home ?? homedir() })
344
-
345
- const disposeFns = []
346
- const result = { port, host, paths, chainFilePath, ready: false, detail: null, dispose: async () => { for (const f of disposeFns.reverse()) await f().catch(() => {}) } }
347
- /**
348
- * 链的状态(进快照 → 面板据此显示来源/是否已生效/还有没有差异)。
349
- * `appliedAt` 只在对运行时**复核过**之后才更新 —— 它表达的是"运行时真跟上了",
350
- * 不是"我们发过请求了"。
351
- */
352
- const chainState = {
353
- tiersPath: chainFilePath,
354
- source: null,
355
- from: null,
356
- tiers: null,
357
- problems: [],
358
- error: null,
359
- marker: null,
360
- changedAt: null,
361
- appliedAt: null,
362
- drift: null,
363
- lastError: null,
364
- }
365
- result.chain = chainState
366
- const publish = () => {
367
- try {
368
- onState?.({ ready: result.ready, detail: result.detail, port, host, paths, chain: chainState })
369
- } catch {
370
- /* 视图的问题不是记账的问题 */
371
- }
372
- }
373
- let env = null
374
- let rules = []
375
- let bootstrapping = null
376
-
377
- /**
378
- * 读链基座 + 读链文件 → 得出**实际要用的**那份链(判断逻辑在 chain.js 的 resolveChain,纯函数可测)。
379
- * `effective === null` 表示"链文件坏了,当作未变更"——调用方这时**什么都不改**。
380
- */
381
- const deriveChain = async (file = null) => {
382
- const { chain, from } = await readChain(chainPath, bundledChainPath)
383
- const chainFile = file ?? (await readChainFile({ path: chainFilePath, readFileImpl: readFile }))
384
- const resolved = resolveChain({ base: chain, from, file: chainFile, previous: chainState })
385
- return { base: chain, from, file: chainFile, ...resolved }
386
- }
387
-
388
- const noteChain = (d, { changedAt = null, appliedAt = null, lastError = null } = {}) => {
389
- // 坏文件只更新"有问题"这件事 —— 别把上一个好状态(来源/档位)冲掉
390
- chainState.error = d.file?.error ?? null
391
- chainState.marker = d.file?.marker ?? null
392
- if (d.stale) {
393
- chainState.problems = d.problems
394
- if (changedAt !== null) chainState.changedAt = changedAt
395
- if (lastError !== null) chainState.lastError = lastError
396
- return
397
- }
398
- chainState.source = d.source
399
- chainState.from = d.from
400
- chainState.derived = d.derived
401
- chainState.tiers = d.tiers
402
- chainState.problems = d.problems
403
- if (changedAt !== null) chainState.changedAt = changedAt
404
- if (appliedAt !== null) chainState.appliedAt = appliedAt
405
- if (lastError !== null) chainState.lastError = lastError
406
- }
407
-
408
- /** 取件 → 派生配置 → 拉起/复用 → 等就绪 → 收敛。可重复调用(幂等)。 */
409
- const bootstrap = async () => {
410
- const binaryState = await ensureBinary({ paths, fetchImpl, logger })
411
- const d = await deriveChain()
412
- // 启动路径上"坏文件"没有上一份好的可留 —— 用基座(它至少是能跑的)
413
- const effective = d.effective ?? d.base
414
- const providers = Object.entries(effective.providers ?? {}).map(([id, v]) => ({ id, ...v }))
415
- const derived = deriveProviderEnv(await readAuth(auth), providers)
416
- if (derived.missing.length > 0) logger.debug(`[reeve] auth.json 里没有这些 provider 的 key:${derived.missing.join(', ')}`)
417
- const proxyConfig = buildProxyConfig(effective, { noAuth: opts.requireVirtualKey !== true })
418
- await mkdir(paths.root, { recursive: true })
419
- await writeFile(paths.config, JSON.stringify(proxyConfig, null, 2), 'utf8')
420
- const token = await readOrCreateToken(paths.token)
421
- rules = proxyConfig.governance?.routing_rules ?? []
422
- env = { ...process.env, ...derived.env, BIFROST_SETUP_TOKEN: token }
423
- noteChain(d)
424
- logger.debug(
425
- `[reeve] 代理链来自 ${d.source}${d.derived ? '(链文件派生)' : '(链基座,未发现可用链文件)'}` +
426
- `(${providers.length} 个 provider,${rules.length} 条档位规则)` +
427
- (d.problems.length > 0 ? `;⚠️ ${d.problems.join(';')}` : ''),
428
- )
429
-
430
- const owner = await ownerOfPort({ dir: paths.root, readdirImpl: readdirImpl ?? readdir, readFileImpl: readFile })
431
- if (await healthy({ port, host, fetchImpl })) {
432
- if (owner === 'foreign') {
433
- // 端口被别人占着(多半是启动器形态起的那个,要 VK)—— 不去抢、也不复用,吵。
434
- result.detail = `:${port} 上有一个**不属于本目录**的代理在跑(模式冲突:它要 virtual key,本插件形态用占位 key)——先停掉它,或把 options.proxy.enabled 关掉`
435
- result.ready = false
436
- logger.debug(`[reeve] ${result.detail}`)
437
- return false
438
- }
439
- result.detail = `复用已在跑的代理(:${port})`
440
- } else {
441
- launch()
442
- result.detail = `代理已拉起(:${port})${binaryState === 'installed' ? ',二进制是本次装好的' : ''}`
443
- }
444
- const deadline = Date.now() + readyTimeoutMs
445
- while (Date.now() < deadline) {
446
- if (await healthy({ port, host, fetchImpl })) {
447
- result.ready = true
448
- publish()
449
- const c = await convergeRules({ port, host, token: env.BIFROST_SETUP_TOKEN, desired: rules, logger })
450
- noteChain(d, { appliedAt: Date.now(), lastError: c.detail })
451
- chainState.drift = c.drift
452
- publish()
453
- logger.debug(`[reeve] ${result.detail},已就绪`)
454
- return true
455
- }
456
- await new Promise((r) => setTimeout(r, 500))
457
- }
458
- result.detail = `${result.detail},但 /api/version 在 ${readyTimeoutMs} ms 内未就绪(看 ${paths.log})`
459
- publish()
460
- logger.debug(`[reeve] ${result.detail}`)
461
- return false
462
- }
463
-
464
- /**
465
- * 链文件变了 → 重新派生 → 写回自持的 config.json(重启后仍是这份)→ 热更运行时规则。
466
- * 代理没就绪时只更新状态,等 bootstrap 自己收敛(它会读同一份文件)。
467
- */
468
- const reconcileChain = async (file) => {
469
- const d = await deriveChain(file)
470
- if (d.effective == null) {
471
- // 坏链文件 → 当作"未变更":运行时保持上一份好的。
472
- // 为什么不翻回基座:一次手滑(存盘到一半、少个括号)不该让线上路由整体换掉。
473
- noteChain(d, { changedAt: Date.now(), lastError: '链文件读不动(当作未变更)' })
474
- publish()
475
- logger.debug(`[reeve] 链文件坏,当作未变更(运行时保持上一份):${d.error ?? d.problems.join(';')}`)
476
- return true
477
- }
478
- const proxyConfig = buildProxyConfig(d.effective, { noAuth: opts.requireVirtualKey !== true })
479
- if (env == null) return false // bootstrap 还没跑过(二进制在后台取件);等它
480
- await writeFile(paths.config, JSON.stringify(proxyConfig, null, 2), 'utf8')
481
- rules = proxyConfig.governance?.routing_rules ?? []
482
- noteChain(d, { changedAt: Date.now() })
483
- if (!result.ready) {
484
- publish()
485
- return true
486
- }
487
- const c = await convergeRules({ port, host, token: env.BIFROST_SETUP_TOKEN, desired: rules, logger })
488
- chainState.appliedAt = Date.now()
489
- chainState.drift = c.drift
490
- chainState.lastError = c.detail
491
- publish()
492
- logger.debug(
493
- `[reeve] 链文件变了(${d.source})→ 运行时规则已对齐(改/建/删 ${c.changed} 条)` +
494
- (d.problems.length > 0 ? `;⚠️ ${d.problems.join(';')}` : ''),
495
- )
496
- return true
497
- }
498
-
499
- /** 链文件有没有变(只 stat;不变就是一次 stat 的开销)。 */
500
- const chainFileChanged = async () => {
501
- const file = await readChainFile({ path: chainFilePath, readFileImpl: readFile })
502
- // 从来没有过这份文件(marker 还是 null)→ 不折腾:回落链基座,与从前行为一致
503
- if (!file.exists) return chainState.marker == null ? null : file
504
- if (file.marker === chainState.marker) return null
505
- return file
506
- }
507
-
508
- const launch = () => {
509
- const [bin, args] = buildSpawnArgs({ paths, port, host })
510
- const child = spawnImpl(bin, args, { cwd: paths.root, env: env ?? process.env, stdio: 'ignore', detached: false })
511
- child.unref?.()
512
- logger.debug(`[reeve] 代理已拉起(pid ${child.pid ?? '?'},:${port})`)
513
- return child
514
- }
515
-
516
- try {
517
- // 快路径:二进制若已在,bootstrap 很快(起/复用 + 等就绪),可以在 setup 里等
518
- // 慢路径:二进制不在 → 后台 bootstrap(不挡引擎启动)
519
- let present = false
520
- try {
521
- present = !needsDownload(await stat(paths.binary))
522
- } catch {
523
- present = false
524
- }
525
- if (present) {
526
- await bootstrap().catch((err) => {
527
- result.detail = `代理自持失败:${String(err).slice(0, 200)}`
528
- logger.debug(`[reeve] ${result.detail}`)
529
- })
530
- } else {
531
- result.detail = `代理二进制首次取件中(${BIFROST_PIN.size} B)—— 后台下载,不挡引擎启动`
532
- publish()
533
- logger.debug(`[reeve] ${result.detail}`)
534
- bootstrapping = bootstrap()
535
- .catch((err) => {
536
- result.detail = `取件失败:${String(err).slice(0, 160)}`
537
- publish()
538
- logger.debug(`[reeve] ${result.detail}`)
539
- return false
540
- })
541
- .finally(() => {
542
- bootstrapping = null
543
- publish()
544
- })
545
- }
546
-
547
- // 链文件守护:**只 stat**;变了才重新派生 + 热更运行时规则(所以面板保存后 ≤chainWatchMs 生效)
548
- if (chainWatchMs > 0) {
549
- const timer = setInterval(async () => {
550
- try {
551
- const file = await chainFileChanged()
552
- if (!file) return
553
- await reconcileChain(file)
554
- } catch (err) {
555
- logger.debug(`[reeve] 链文件热更失败(忽略):${String(err).slice(0, 140)}`)
556
- }
557
- }, chainWatchMs)
558
- timer.unref?.()
559
- disposeFns.push(() => clearInterval(timer))
560
- }
561
-
562
- // 守护:没就绪就把 bootstrap 再走一遍(取件幂等);已就绪只探活并按需重启
563
- if (superviseMs > 0) {
564
- const timer = setInterval(async () => {
565
- try {
566
- if (!result.ready) {
567
- if (bootstrapping) return // 后台那次还没跑完
568
- await bootstrap()
569
- return
570
- }
571
- if (await healthy({ port, host, fetchImpl })) return
572
- logger.debug(`[reeve] 代理失联,重新拉起(:${port})`)
573
- launch()
574
- } catch (err) {
575
- logger.debug(`[reeve] 守护巡检失败(忽略):${String(err).slice(0, 140)}`)
576
- }
577
- }, superviseMs)
578
- timer.unref?.()
579
- disposeFns.push(() => clearInterval(timer))
580
- }
581
- // dispose 不杀代理:它可以在引擎重启之间活着(下次启动会"复用")
582
- } catch (err) {
583
- result.detail = `代理自持失败:${String(err).slice(0, 200)}`
584
- logger.debug(`[reeve] ${result.detail}`)
585
- }
586
- return result
587
- }