@bd7pil/reeve 0.1.4 → 0.1.6

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.
@@ -0,0 +1,129 @@
1
+ // 会话状态机(GATE 里唯一的一份回退状态)。
2
+ //
3
+ // 语义(复刻自 v1 时代那套 fallback 的行为规格):
4
+ // · **单向推进**:候选指针只增不减(attemptCount/fallbackIndex 语义);
5
+ // · **粘性**:切换成功后,同一 session 的后续请求从切换到的那个候选起(不回头);
6
+ // · **冷却**:失败的候选取记时间戳,冷却期(默认 60s)内不再被选;
7
+ // · **可选恢复**:`restore_primary_after_cooldown` 为真且所有失败记录的冷却都过期 → 指针归零;
8
+ // · **并发安全**:同一 session 的两次失败只推进一次(`advance` 带去重键);
9
+ // · **有界内存**:按 TTL 驱逐 + 条数上限。
10
+ //
11
+ // 时间是注入的(测试用假时钟,不 sleep)。
12
+ export function createSessionStore({ now = Date.now, policy = {}, maxSessions = 10_000 } = {}) {
13
+ // policy 允许传函数(配置热更后策略即时生效,状态不必重建)
14
+ const readPolicy = () => (typeof policy === 'function' ? policy() ?? {} : policy)
15
+ const ttlMs = () => Math.max(1, Number(readPolicy().session_ttl_seconds ?? 1800)) * 1000
16
+ const cooldownMs = () => Math.max(0, Number(readPolicy().cooldown_seconds ?? 60)) * 1000
17
+ /** @type {Map<string, {agent: string|null, touchedAt: number, chains: Map<string, {index: number, failed: Map<string, number>, lastNext: {key: string, model: string|null}|null}>}>} */
18
+ const sessions = new Map()
19
+
20
+ const record = (sessionID) => {
21
+ let rec = sessions.get(sessionID)
22
+ if (!rec) {
23
+ rec = { agent: null, touchedAt: now(), chains: new Map() }
24
+ sessions.set(sessionID, rec)
25
+ }
26
+ rec.touchedAt = now()
27
+ return rec
28
+ }
29
+ const chainRec = (sessionID, chainName) => {
30
+ const rec = record(sessionID)
31
+ let c = rec.chains.get(chainName)
32
+ if (!c) {
33
+ c = { index: 0, failed: new Map(), lastNext: null }
34
+ rec.chains.set(chainName, c)
35
+ }
36
+ return c
37
+ }
38
+ const sweep = () => {
39
+ const cutoff = now() - ttlMs()
40
+ for (const [id, rec] of sessions) if (rec.touchedAt < cutoff) sessions.delete(id)
41
+ if (sessions.size <= maxSessions) return
42
+ // 超上限:按 touchedAt 从旧到新驱逐
43
+ const ordered = [...sessions.entries()].sort((a, b) => a[1].touchedAt - b[1].touchedAt)
44
+ for (let i = 0; i < ordered.length - maxSessions; i++) sessions.delete(ordered[i][0])
45
+ }
46
+
47
+ const cooling = (rec, key) => {
48
+ const failedAt = rec.failed.get(key)
49
+ if (failedAt === undefined) return false
50
+ if (cooldownMs() === 0) return false
51
+ const alive = now() - failedAt < cooldownMs()
52
+ if (!alive) rec.failed.delete(key) // 过期即清(顺带把恢复判断简化)
53
+ return alive
54
+ }
55
+
56
+ return {
57
+ now,
58
+ cooldownMs,
59
+ setAgent(sessionID, agent) {
60
+ if (typeof sessionID !== 'string' || sessionID.length === 0) return
61
+ record(sessionID).agent = typeof agent === 'string' && agent.length > 0 ? agent : null
62
+ sweep()
63
+ },
64
+ agentOf(sessionID) {
65
+ const rec = sessions.get(sessionID)
66
+ if (!rec) return null
67
+ if (now() - rec.touchedAt >= ttlMs()) {
68
+ sessions.delete(sessionID)
69
+ return null
70
+ }
71
+ return rec.agent
72
+ },
73
+ /** 该 session 在这条链上的起点索引(粘性 + 冷却后恢复)。 */
74
+ startIndex(sessionID, chainName, keys) {
75
+ const rec = chainRec(sessionID, chainName)
76
+ if (rec.index > 0 && readPolicy().restore_primary_after_cooldown === true) {
77
+ const anyCooling = keys.some((k) => cooling(rec, k))
78
+ if (!anyCooling) rec.index = 0
79
+ }
80
+ return Math.min(rec.index, Math.max(0, keys.length - 1))
81
+ },
82
+ /** 某个候选此刻是不是在冷却里。 */
83
+ isCooling(sessionID, chainName, key) {
84
+ return cooling(chainRec(sessionID, chainName), key)
85
+ },
86
+ /** 失败:记时间戳(冷却起点)。**不**推进指针(推进由 noteSuccess/advance 决定)。 */
87
+ noteFailure(sessionID, chainName, key) {
88
+ const rec = chainRec(sessionID, chainName)
89
+ rec.failed.set(key, now())
90
+ sweep()
91
+ },
92
+ /** 成功:把指针钉在这个候选上(粘性)。 */
93
+ noteSuccess(sessionID, chainName, key, keys) {
94
+ const rec = chainRec(sessionID, chainName)
95
+ const at = keys.indexOf(key)
96
+ if (at >= 0) rec.index = at
97
+ rec.failed.delete(key)
98
+ sweep()
99
+ },
100
+ /**
101
+ * 插件侧要"下一个候选"时用(流已开始/沉默这两类层内换不了的情形)。
102
+ * `dedupeKey` 保证同一次失败被两条事件各触发一次时**只推进一次**。
103
+ * @returns {{ model: string|null, index: number }} model 为 null = 没有下一个了
104
+ */
105
+ advance(sessionID, chainName, keys, { dedupeKey = null } = {}) {
106
+ const rec = chainRec(sessionID, chainName)
107
+ if (dedupeKey !== null && rec.lastNext?.key === dedupeKey) return { model: rec.lastNext.model, index: rec.index }
108
+ const from = Math.max(rec.index, 0)
109
+ let nextIndex = -1
110
+ for (let i = from + 1; i < keys.length; i++) {
111
+ if (cooling(rec, keys[i])) continue
112
+ nextIndex = i
113
+ break
114
+ }
115
+ const model = nextIndex >= 0 ? keys[nextIndex] : null
116
+ if (nextIndex >= 0) rec.index = nextIndex
117
+ rec.lastNext = { key: dedupeKey, model }
118
+ sweep()
119
+ return { model, index: rec.index }
120
+ },
121
+ snapshot() {
122
+ return {
123
+ sessions: sessions.size,
124
+ cooling: [...sessions.values()].reduce((n, r) => n + [...r.chains.values()].reduce((m, c) => m + [...c.failed.keys()].filter((k) => cooling(c, k)).length, 0), 0),
125
+ }
126
+ },
127
+ size: () => sessions.size,
128
+ }
129
+ }
package/src/index.js CHANGED
@@ -33,15 +33,16 @@ import { Supervisor } from './supervisor.js'
33
33
  import { reconcileParent } from './reconcile.js'
34
34
  import { SnapshotWriter, buildSnapshot, projectAgentTable } from './snapshot.js'
35
35
  import { SUBAGENT_ROSTER, withRoster } from './roster.js'
36
- import { readSetupToken, startOwnedProxy } from './proxy.js'
37
- import { REEVE_TIERS, BUILTINS_TO_HIDE, mergeTierModels, pickDefaultAgent, readEndpoint } from './models.js'
38
- import { STATS_DEFAULTS, aggregateBifrostRows, aggregateLedger, buildStats, fetchBifrostLogs, fetchBifrostModels, mergeModelCandidates } from './stats.js'
36
+ import { probeGate, startGate } from './gate/gate.js'
37
+ import { defaultFallbackConfigPath } from './gate/config.js'
38
+ import { REEVE_TIERS, BUILTINS_TO_HIDE, mergeTierModels, pickDefaultAgent, readEndpoint, resolveGateEndpoint } from './models.js'
39
+ import { STATS_DEFAULTS, aggregateGateRows, aggregateLedger, buildStats, fetchGateModels, mergeModelCandidates, readLedgerTail } from './stats.js'
39
40
  import { loadAgents, loadSkills } from './register.js'
40
41
  import { ensureProviderBlock, reeveProviderBlock } from './config-write.js'
41
42
 
42
43
  const SUBAGENT_TOOL = 'subagent'
43
- /** 自持代理的默认端口(与写进配置的端点一致)。 */
44
- const PROXY_DEFAULT_PORT = 8080
44
+ /** GATE(唯一代理层)的默认端口(与写进配置的端点一致)。 */
45
+ const GATE_DEFAULT_PORT = 8080
45
46
 
46
47
  export default {
47
48
  id: PLUGIN_ID,
@@ -73,10 +74,8 @@ export default {
73
74
  // 名册注入的证据:注入成功过一次就记下来(进快照)——"藏起来不削弱委派"这条设计
74
75
  // 靠它可核对,而不是靠"模型应该看得见"。
75
76
  let roster = null
76
- /** 代理层自持的状态(进快照:面板可显示、上机可核对)。必须在快照块之前声明。 */
77
- let proxyState = null
78
- /** 链文件的状态(代理那边在维护,这里只留引用给快照)。必须在快照块之前声明。 */
79
- let chainRef = null
77
+ /** GATE 的运行态(进快照:面板可显示、上机可核对)。必须在快照块之前声明。 */
78
+ let gateState = null
80
79
  /** 性能统计(进快照)。必须在快照块之前声明。 */
81
80
  let statsState = null
82
81
  /** 模型候选(面板的链编辑选择器用)。必须在快照块之前声明。 */
@@ -130,12 +129,11 @@ export default {
130
129
  version: PLUGIN_VERSION,
131
130
  agents: agentTable,
132
131
  roster,
133
- proxy: proxyState,
134
- chain: chainRef,
135
132
  models: modelsState,
136
133
  registered: { agents: registeredAgents, skills: registeredSkills },
137
134
  endpoint: endpointState,
138
135
  stats: statsState,
136
+ gate: gateState,
139
137
  })
140
138
  snapshotFlush = () => snapshot.schedule(build)
141
139
  ledger.onChange = () => snapshot.schedule(build)
@@ -149,7 +147,7 @@ export default {
149
147
  // (只加不改 / 备份 / 写完自校验 / 保留注释)。
150
148
  let endpointWrite = null
151
149
  if (options.ownEndpoint !== false) {
152
- const wantPort = Number(options.proxy?.port ?? PROXY_DEFAULT_PORT)
150
+ const wantPort = resolveGateEndpoint({ gate: options.gate === true ? null : options.gate, proxy: options.proxy === true ? null : options.proxy, fallbackPort: GATE_DEFAULT_PORT }).port
153
151
  endpointWrite = await ensureProviderBlock({
154
152
  configDir: options.engineConfigDir ?? null,
155
153
  block: reeveProviderBlock({ port: wantPort }),
@@ -159,60 +157,74 @@ export default {
159
157
  endpointState = { status: endpointWrite.status, path: endpointWrite.path, detail: endpointWrite.detail }
160
158
  }
161
159
 
162
- // ---- 代理层自持(opt-in:options.proxy.enabled)----
163
- // 纯插件形态:取件 → 派生链 → 拉起/复用 → 等就绪 → 收敛 → 守护。
164
- // 不需要任何外部脚本;失败只记日志(代理起不来时引擎照常跑,模型会吵地报错)。
165
- let proxyHandle = null
166
- // 两种写法都行:`proxy: true`(简写)或 `proxy: { enabled: true, ... }`
167
- // 默认开:这样配置里可以真的只写一行插件(`proxy: false` 关闭;`proxy: true` 或对象都行)
168
- if (options.proxy !== false && options.proxy?.enabled !== false) {
169
- // 端口不用用户重复写:从配置里的 `providers.reeve.settings.baseURL` 推(读不到就用 options/默认)
170
- let opts = options.proxy === true || options.proxy === undefined ? {} : options.proxy
171
- if (opts.port == null) {
172
- const endpoint = await readEndpoint({})
173
- if (endpoint) {
174
- opts = { ...opts, port: endpoint.port, host: endpoint.host }
175
- logger.debug(`[reeve] 代理端口取自配置:${endpoint.host}:${endpoint.port}(来自 ${endpoint.from})`)
160
+ // ---- GATE(代理层 —— 唯一入口;opt-out:`gate: false`)----
161
+ // 引擎认的是 `providers.reeve` 那个 baseURL,GATE 就占那个端口;路由/回退/执行/台账都在
162
+ // 它里面(唯一代理层,见 docs/GATE.md)。失败只记日志:引擎照常跑,模型会吵地报错。
163
+ //
164
+ // **复用优先**:引擎是**按工作区**加载插件的 —— 同一个引擎里可能有多个插件实例(每个工作区一个)。
165
+ // 第一个实例把 GATE 拉到端点口上,后面的实例**用健康检查接上**(`external: true`),
166
+ // 而不是去抢同一个口(抢必失败 → 快照里会出现假的"GATE 未就绪",面板跟着误报)。
167
+ // 卸载语义:**都不在卸载时关端口** —— GATE 是引擎级服务,插件实例(工作区)被闲置驱逐
168
+ // 并不意味着引擎退出。以前"自己起的自己关"会让起服务的工作区被驱逐后,其它活跃工作区的
169
+ // 请求永久拿不到代理(2026-10-22 两台机器实测)。`detach()` 只清空闲连接,不阻塞进程退出。
170
+ let gateHandle = null
171
+ if (options.gate !== false && options.gate?.enabled !== false) {
172
+ const endpoint = await readEndpoint({})
173
+ const { port: gatePort, host: gateHost } = resolveGateEndpoint({
174
+ gate: options.gate === true ? null : options.gate,
175
+ proxy: options.proxy === true ? null : options.proxy,
176
+ endpoint,
177
+ fallbackPort: GATE_DEFAULT_PORT,
178
+ })
179
+ const configPath = options.gate?.configPath ?? options.fallbackConfigPath ?? defaultFallbackConfigPath()
180
+ const probe = await probeGate({ port: gatePort, host: gateHost })
181
+ if (probe.ok) {
182
+ gateState = { ...probe.snapshot, reused: true }
183
+ logger.debug(`[reeve] GATE 已在 :${gatePort} 上跑着(复用;链 ${(probe.snapshot?.chains ?? []).join('/')})`)
184
+ } else {
185
+ try {
186
+ gateHandle = await startGate({
187
+ port: gatePort,
188
+ host: gateHost,
189
+ configPath,
190
+ ledgerPath: options.gate?.ledgerPath ?? undefined,
191
+ logger,
192
+ })
193
+ gateState = gateHandle.snapshot()
194
+ logger.debug(`[reeve] GATE::${gateHandle.port}(链 ${gateState.chains.join('/')})`)
195
+ } catch (err) {
196
+ gateState = { error: String(err).slice(0, 200) }
197
+ logger.debug(`[reeve] GATE 起不来(忽略,引擎照常):${gateState.error}`)
176
198
  }
177
199
  }
178
- // 状态一变就刷新快照 —— 尤其"后台取件完成、代理起来了"那一刻(否则面板会一直显示未就绪)
179
- const onState = (st) => {
180
- proxyState = { ready: st.ready, detail: st.detail, port: st.port, host: st.host }
181
- // 链的状态对象由代理那边持续更新(热更、收敛复核都会写它)—— 这里只留引用,快照时再取
182
- if (st.chain) chainRef = st.chain
183
- snapshotFlush()
184
- }
185
- proxyHandle = await startOwnedProxy({ opts, logger, onState })
186
- onState(proxyHandle)
187
- logger.debug(`[reeve] 代理层自持:${proxyHandle.ready ? '就绪' : '未就绪'} —— ${proxyHandle.detail ?? '?'}`)
200
+ // 状态一变就刷快照 —— 尤其"起来了"或"起不来(如实报错)"这一刻。
201
+ // 少了这一次,消费方只能等下一次台账变化才看到 gate 段(起不来时会一直显示 null)。
202
+ snapshotFlush()
188
203
  }
189
204
 
190
205
  const admission = acquireAdmissionRuntime(ctx.location?.directory, { limits })
191
206
 
192
- // ---- ⑦ 性能统计(Bifrost 归因 + 台账聚合 → 快照)----
193
- // 为什么在插件里做而不是面板里做:面板跑在 openchamber 的 iframe 里,**没有网络能力**,
194
- // 只有宿主给的 readFile 与 writeFile。而 `/api/logs`、`/v1/models` 是本机回环的 HTTP ——
195
- // 只有引擎进程里的插件够得着。所以插件取数 + 聚合,面板只读快照。
207
+ // ---- ⑦ 性能统计(GATE 台账聚合 + 编排台账聚合 → 快照)----
208
+ // 为什么在插件里做而不是面板里做:面板跑在 openchamber 的 iframe 里,**没有网络/文件能力**,
209
+ // 只有宿主给的 readFile 与 writeFile。台账是引擎进程能读到的本地文件;模型目录是本机回环
210
+ // 的 HTTP(GATE 的 `/v1/models`)—— 只有插件够得着。所以插件取数 + 聚合,面板只读快照。
196
211
  const statsOpts = typeof options.stats === 'object' && options.stats !== null ? options.stats : {}
197
212
  const statsIntervalMs = Number(statsOpts.intervalMs ?? STATS_DEFAULTS.intervalMs)
198
213
  const statsLimit = Number(statsOpts.limit ?? STATS_DEFAULTS.limit)
199
214
  const statsWindowMs = Number(statsOpts.windowMs ?? STATS_DEFAULTS.windowMs)
200
- // 模型目录多久刷一次。**别跟统计同频**:`/v1/models` 是一次真去上游的探测,
201
- // Bifrost 会为每个 provider 记一行日志(实测一次探测 = 7 行)—— 一分钟一次会把
202
- // 用户自己的归因日志灌满探测噪声。模型目录本身变化很慢,半小时足够。
215
+ // 模型目录多久刷一次。**别跟统计同频**:`/v1/models` 会真去上游探测池(9router 那条),
216
+ // 一分钟一次没必要。模型目录本身变化很慢,半小时足够。
203
217
  const modelsIntervalMs = Number(statsOpts.modelsIntervalMs ?? STATS_DEFAULTS.modelsIntervalMs)
204
218
  let statsTimer = null
205
- let statsSignature = null
206
219
  let statsInFlight = false
207
220
  let lastModelsAt = 0
208
221
 
209
222
  /** 链上已经在用的模型(面板选择器的候选之一:发现不全时至少这些能选)。 */
210
223
  const chainedRefs = () => {
211
224
  const out = []
212
- for (const spec of Object.values(chainRef?.tiers ?? {})) {
213
- for (const t of spec?.targets ?? []) if (t?.model) out.push(`${t.provider}/${t.model}`)
214
- for (const f of spec?.fallbacks ?? []) {
215
- const s = String(f ?? '')
225
+ for (const targets of Object.values(gateState?.chain_targets ?? {})) {
226
+ for (const ref of targets ?? []) {
227
+ const s = String(ref ?? '')
216
228
  if (s && !s.endsWith('/') && s.includes('/')) out.push(s)
217
229
  }
218
230
  }
@@ -221,53 +233,35 @@ export default {
221
233
  const engineModels = () => REEVE_TIERS.map((t) => `reeve/${t.id}`)
222
234
 
223
235
  const refreshStats = async () => {
224
- if (!proxyHandle || statsInFlight) return
236
+ if (!gateHandle || statsInFlight) return
225
237
  statsInFlight = true
226
238
  const now = Date.now()
227
- const { port, host, paths } = proxyHandle
239
+ const nowMs = now
228
240
  try {
229
- const token = await readSetupToken(paths.token)
230
- if (!token) {
231
- statsState = buildStats({ now, windowMs: statsWindowMs, source: 'unavailable', error: '读不到代理的 setup token(统计接口要它)', checkedAt: now })
232
- snapshotFlush()
233
- return
234
- }
235
- // 先探一行:`total_count` + 最新 id 没变就说明没有新调用 —— 省掉一次上百行的抓取
236
- const probe = await fetchBifrostLogs({ port, host, token, limit: 1 })
237
- if (probe.error) {
238
- statsState = buildStats({ now, windowMs: statsWindowMs, source: 'unavailable', error: `读 /api/logs 失败:${probe.error}`, checkedAt: now })
239
- snapshotFlush()
240
- return
241
- }
242
- if (probe.signature && probe.signature === statsSignature && statsState?.bifrost) {
243
- statsState = { ...statsState, checkedAt: now }
244
- snapshotFlush()
245
- return
246
- }
247
- const full = await fetchBifrostLogs({ port, host, token, limit: statsLimit })
248
- statsSignature = full.signature
249
- const bifrost = full.error ? null : aggregateBifrostRows(full.rows, { now, windowMs: statsWindowMs })
250
- const ledgerAgg = aggregateLedger(ledger.all(), { now })
241
+ const tail = await readLedgerTail({ path: gateHandle.ledgerPath, limit: statsLimit })
242
+ const gateAgg = tail.error ? null : aggregateGateRows(tail.rows, { now: nowMs, windowMs: statsWindowMs })
243
+ const ledgerAgg = aggregateLedger(ledger.all(), { now: nowMs })
251
244
  statsState = buildStats({
252
- now,
253
- checkedAt: now,
245
+ now: nowMs,
246
+ checkedAt: nowMs,
254
247
  windowMs: statsWindowMs,
255
- source: bifrost ? 'bifrost.api' : 'unavailable',
256
- error: full.error ? `读 /api/logs 失败:${full.error}` : null,
257
- bifrost,
248
+ source: gateAgg ? 'gate.ledger' : 'unavailable',
249
+ error: tail.error ? `读台账失败:${tail.error}` : null,
250
+ gate: gateAgg,
258
251
  orchestrate: {
259
252
  ...ledgerAgg,
260
253
  queue: { active: admission.activeCount, waiting: admission.waitingCount },
261
254
  },
262
255
  })
256
+ gateState = gateHandle.snapshot()
263
257
  // 顺手把模型候选也刷一遍(半小时一次;链编辑器的选择器用它)
264
- if (now - lastModelsAt >= modelsIntervalMs) {
265
- const discovered = await fetchBifrostModels({ port, host })
266
- lastModelsAt = now
258
+ if (nowMs - lastModelsAt >= modelsIntervalMs) {
259
+ const discovered = await fetchGateModels({ port: gateHandle.port, host: gateHandle.host })
260
+ lastModelsAt = nowMs
267
261
  const candidates = mergeModelCandidates({ discovered: discovered.models, chained: chainedRefs(), engine: engineModels() })
268
262
  modelsState = {
269
- generatedAt: now,
270
- source: discovered.error ? 'chain+engine' : 'bifrost',
263
+ generatedAt: nowMs,
264
+ source: discovered.error ? 'chain+engine' : 'gate',
271
265
  error: discovered.error,
272
266
  discovered: discovered.models.length,
273
267
  candidates,
@@ -281,11 +275,11 @@ export default {
281
275
  }
282
276
  }
283
277
 
284
- if (statsIntervalMs > 0 && proxyHandle) {
278
+ if (statsIntervalMs > 0 && gateHandle) {
285
279
  void refreshStats()
286
280
  statsTimer = setInterval(() => void refreshStats(), statsIntervalMs)
287
281
  statsTimer.unref?.()
288
- logger.debug(`[reeve] 性能统计:每 ${Math.round(statsIntervalMs / 1000)}s 刷一次(近 ${statsLimit} 行 / ${Math.round(statsWindowMs / 3600000)}h 窗口)`)
282
+ logger.debug(`[reeve] 性能统计:每 ${Math.round(statsIntervalMs / 1000)}s 刷一次(台账尾 ${statsLimit} 行 / ${Math.round(statsWindowMs / 3600000)}h 窗口)`)
289
283
  }
290
284
  const supervisor = new Supervisor({
291
285
  ledger,
@@ -571,7 +565,9 @@ export default {
571
565
  snapshot.cancel()
572
566
  void snapshot.flush().catch(() => {})
573
567
  }
574
- if (proxyHandle) void proxyHandle.dispose().catch(() => {})
568
+ // GATE 是引擎级服务:卸载只"松手"(清空闲连接、不关监听口)。真正关端口只在进程退出
569
+ // 或测试里显式 dispose;`detach` 的定时器都是 unref 的,CLI 一次性进程照样能立刻退出。
570
+ if (gateHandle) gateHandle.detach?.()
575
571
  logger.debug('[reeve] 已卸载')
576
572
  }
577
573
  },
package/src/models.js CHANGED
@@ -112,3 +112,14 @@ export function pickDefaultAgent(editor, id = 'foreman') {
112
112
  }
113
113
  return false
114
114
  }
115
+
116
+ /**
117
+ * GATE 该在哪个 `host:port` 上服务(纯函数,四级回落:显式 gate → 旧 proxy 键 → 配置里的端点 → 默认)。
118
+ * 抽出来是为了**可测**:真实回落链要碰机器上的真实配置(和真实的 18080),不该在单测里跑。
119
+ */
120
+ export function resolveGateEndpoint({ gate = null, proxy = null, endpoint = null, fallbackPort = 8080, fallbackHost = '127.0.0.1' } = {}) {
121
+ return {
122
+ port: Number(gate?.port ?? proxy?.port ?? endpoint?.port ?? fallbackPort),
123
+ host: gate?.host ?? endpoint?.host ?? fallbackHost,
124
+ }
125
+ }
package/src/snapshot.js CHANGED
@@ -92,7 +92,7 @@ export class SnapshotWriter {
92
92
  */
93
93
  export function buildSnapshot(
94
94
  records,
95
- { now = Date.now(), version = null, agents = null, roster = null, proxy = null, registered = null, endpoint = null, chain = null, models = null, stats = null } = {},
95
+ { now = Date.now(), version = null, agents = null, roster = null, registered = null, endpoint = null, models = null, stats = null, gate = null } = {},
96
96
  ) {
97
97
  const tasks = records
98
98
  .map((r) => ({
@@ -123,8 +123,7 @@ export function buildSnapshot(
123
123
  version,
124
124
  agentTable: agents,
125
125
  roster,
126
- proxy,
127
- chain: chain == null ? null : projectChain(chain),
126
+ gate,
128
127
  models,
129
128
  registered,
130
129
  endpoint,
@@ -134,27 +133,6 @@ export function buildSnapshot(
134
133
  }
135
134
  }
136
135
 
137
- /**
138
- * 链的状态 → 面板要的那一小块。
139
- * `tiers` 就是 `~/.reeve/chain.json` 里那份形状(面板编辑的就是它,写回也是它)。
140
- */
141
- export function projectChain(chain) {
142
- if (!chain) return null
143
- return {
144
- tiersPath: chain.tiersPath ?? null,
145
- source: chain.source ?? null,
146
- from: chain.from ?? null,
147
- derived: chain.derived === true,
148
- tiers: chain.tiers ?? null,
149
- problems: chain.problems ?? [],
150
- error: chain.error ?? null,
151
- changedAt: chain.changedAt ?? null,
152
- appliedAt: chain.appliedAt ?? null,
153
- drift: chain.drift ?? null,
154
- lastError: chain.lastError ?? null,
155
- }
156
- }
157
-
158
136
  /**
159
137
  * 从 `ctx.agent.list()` 的返回里抽出"谁可见"的最小形状。
160
138
  * 读不到就返回 null —— 快照里没有这一节,不假装知道。