@dsh-bio/dsh-bio-gem 0.1.12 → 0.1.14

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.
@@ -1,489 +1,511 @@
1
- import { spawn } from 'node:child_process'
2
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
3
- import os from 'node:os'
4
- import { join } from 'node:path'
5
- import { pythonCandidates } from './python.js'
6
-
7
- /**
8
- * dsh-bio-gem — hosted-domain integration protocol v1 (read-only batch).
9
- *
10
- * This module deliberately has no import-time probes: loading the plugin and
11
- * serving /health must never spawn Python or modify the data directory.
12
- */
13
-
14
- export const INTEGRATION_PREFIX = '/api/dsh-bio-gem/integration'
15
- export const PROTOCOL_MAJOR = 1
16
- export const PROTOCOL_MINORS = [0]
17
- export const RUNTIME_PROBE_CACHE_MS = 60_000
18
- /** WSL/gapseq 探测更重(wsl.exe 冷启动可达十秒级),缓存更久且**非阻塞**。 */
19
- export const GAPSEQ_PROBE_CACHE_MS = 300_000
20
- export const INTEGRATION_FEATURES = [
21
- 'status',
22
- 'model-store',
23
- 'ledger',
24
- 'exports',
25
- 'carveme-runtime',
26
- 'gapseq-probe',
27
- ]
28
-
29
- const PLUGIN_ID = 'dsh-bio-gem'
30
- const PLUGIN_VERSION = '0.1.12'
31
-
32
- function defaultDataRoot() {
33
- const dshHome = process.env.DSH_HOME ?? join(os.homedir(), '.dsh')
34
- return join(dshHome, 'dsh-bio-gem')
35
- }
36
-
37
- function listFiles(dir, predicate = () => true) {
38
- try {
39
- return readdirSync(dir, { withFileTypes: true })
40
- .filter((entry) => entry.isFile() && predicate(entry.name))
41
- .map((entry) => {
42
- const fullPath = join(dir, entry.name)
43
- const stat = statSync(fullPath)
44
- return {
45
- name: entry.name,
46
- sizeBytes: stat.size,
47
- modifiedAt: stat.mtime.toISOString(),
48
- }
49
- })
50
- .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
51
- } catch {
52
- return []
53
- }
54
- }
55
-
56
- function boundedSummary(dir, predicate) {
57
- const all = listFiles(dir, predicate)
58
- return { count: all.length, items: all.slice(0, 50) }
59
- }
60
-
61
- function ledgerSummary(root) {
62
- const dir = join(root, 'ledger')
63
- const files = listFiles(dir, (name) => name.endsWith('.jsonl'))
64
- const ledgers = files.map((file) => {
65
- let entries = 0
66
- try {
67
- entries = readFileSync(join(dir, file.name), 'utf8').split(/\r?\n/).filter((line) => line.trim()).length
68
- } catch { /* unreadable ledgers are represented as zero entries */ }
69
- return {
70
- model: file.name.replace(/\.jsonl$/, ''),
71
- entries,
72
- modifiedAt: file.modifiedAt,
73
- }
74
- })
75
- return {
76
- count: ledgers.length,
77
- totalEntries: ledgers.reduce((total, ledger) => total + ledger.entries, 0),
78
- dir,
79
- ledgers: ledgers.slice(0, 50),
80
- }
81
- }
82
-
83
- function carvemeStatus(root) {
84
- const scripts = join(root, 'venv-carveme', 'Scripts')
85
- const carve = existsSync(join(scripts, 'carve.exe'))
86
- const diamond = existsSync(join(scripts, 'diamond.exe'))
87
- return {
88
- available: carve && diamond,
89
- hint: carve && diamond
90
- ? 'carve.exe 与 diamond.exe 均可读。'
91
- : 'gem_build(carveme) 需要私有 venv 中的 carve.exe 与 diamond.exe。',
92
- }
93
- }
94
-
95
- function statusCheck(id, status, detail) {
96
- return { id, status, detail }
97
- }
98
-
99
- function remediationsFor(checks) {
100
- const codes = new Set(checks.filter((check) => check.status !== 'ok').map((check) => check.id))
101
- const remediations = []
102
- if (codes.has('python.cobra')) {
103
- remediations.push({
104
- code: 'genie.bootstrap-python', owner: 'genie',
105
- detail: '准备 BioGenie 共享 Python/cobra 环境后重新探测。',
106
- })
107
- }
108
- if (codes.has('runtime.carveme')) {
109
- remediations.push({
110
- code: 'gem.install-carveme-runtime', owner: 'gem',
111
- detail: '准备 gem 私有 CarveMe 运行时(含 diamond)后重新探测。',
112
- })
113
- }
114
- if (codes.has('runtime.gapseq')) {
115
- remediations.push({
116
- code: 'genie.install-wsl-gapseq', owner: 'genie',
117
- detail: '准备 WSL/gapseq 共享前置能力后重新探测。',
118
- })
119
- }
120
- return remediations
121
- }
122
-
123
- /** Read only the installed cobra version; output and failures stay local. */
124
- function probeCobraVersion(executable) {
125
- return new Promise((resolve) => {
126
- let settled = false
127
- let timer = null
128
- const finish = (value) => {
129
- if (settled) return
130
- settled = true
131
- if (timer) clearTimeout(timer)
132
- resolve(value)
133
- }
134
- try {
135
- const child = spawn(executable, ['-I', '-c', 'import cobra; print(cobra.__version__)'], {
136
- windowsHide: true,
137
- stdio: ['ignore', 'pipe', 'ignore'],
138
- })
139
- let stdout = ''
140
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8') })
141
- child.on('error', () => finish(null))
142
- child.on('close', (code) => finish(code === 0 ? stdout.trim() || 'available' : null))
143
- timer = setTimeout(() => {
144
- try { child.kill() } catch { /* already exited */ }
145
- finish(null)
146
- }, 20_000)
147
- } catch {
148
- finish(null)
149
- }
150
- })
151
- }
152
-
153
- async function probePythonEnvironment(candidateProvider, fileExists, probeCobra) {
154
- const candidates = candidateProvider().map((candidate) => ({
155
- ...candidate,
156
- exists: candidate.path === 'python' ? true : fileExists(candidate.path),
157
- }))
158
- let selected = null
159
- for (const candidate of candidates) {
160
- if (!candidate.exists) continue
161
- let cobraVersion = null
162
- try {
163
- cobraVersion = await probeCobra(candidate.path)
164
- } catch { /* individual candidate failures are an expected degraded state */ }
165
- if (cobraVersion) {
166
- selected = {
167
- path: candidate.path,
168
- source: candidate.source,
169
- cobraVersion,
170
- }
171
- break
172
- }
173
- }
174
- return {
175
- selected,
176
- candidates,
177
- note: selected ? undefined : '所有候选均未通过 import cobra 的只读探测。',
178
- }
179
- }
180
-
181
- /** Execute a fixed, read-only WSL command and retain only a tiny version response. */
182
- function runGapseqVersionCommand(command, args) {
183
- return new Promise((resolve) => {
184
- let settled = false
185
- let stdout = ''
186
- let timer = null
187
- const finish = (result) => {
188
- if (settled) return
189
- settled = true
190
- if (timer) clearTimeout(timer)
191
- resolve(result)
192
- }
193
- try {
194
- // stdin 必须保持 pipe(并立即 end):实测 wsl.exe 在 stdin=ignore 下会极慢
195
- // (同一条 `echo ok`:ignore 14.9s vs pipe 0.26s,约 50 倍),
196
- // 这是之前 gapseq 探测频繁超时的真正根因。
197
- const child = spawn(command, args, {
198
- windowsHide: true,
199
- stdio: ['pipe', 'pipe', 'ignore'],
200
- })
201
- child.stdin?.end()
202
- child.stdout?.on('data', (chunk) => {
203
- if (stdout.length < 512) stdout += chunk.toString('utf8').slice(0, 512 - stdout.length)
204
- })
205
- child.on('error', () => finish({ ok: false }))
206
- child.on('close', (code) => finish({ ok: code === 0, stdout }))
207
- timer = setTimeout(() => {
208
- try { child.kill() } catch { /* already exited */ }
209
- finish({ ok: false, timeout: true })
210
- }, 30_000)
211
- } catch {
212
- finish({ ok: false })
213
- }
214
- })
215
- }
216
-
217
- async function probeGapseqEnvironment({ isWindows, distro, runner }) {
218
- if (!isWindows) {
219
- return { available: false, detail: 'gapseq 仅支持 Windows WSL 的只读探测。' }
220
- }
221
- // 快速预检:先确认目标发行版存在(wsl.exe -l -q 亚秒级),避免发行版缺失时
222
- // 白等一次 bash 长命令直到超时刹车(真实运行时实测:本机 bash 启动即 ~3s)。
223
- try {
224
- const listed = await runner('wsl.exe', ['-l', '-q'])
225
- const names = String(listed?.stdout ?? '')
226
- .replace(/\u0000/g, '')
227
- .split(/\r?\n/)
228
- .map((name) => name.trim())
229
- .filter(Boolean)
230
- if (listed?.ok && !names.includes(distro)) {
231
- return {
232
- available: false,
233
- detail: names.length > 0
234
- ? `WSL 发行版 ${distro} 不存在(现有:${names.join(', ')})。`
235
- : `WSL 未发现发行版(期望 ${distro})。`,
236
- }
237
- }
238
- } catch { /* 预检失败不阻断后续只读探测 */ }
239
- let result
240
- try {
241
- result = await runner('wsl.exe', [
242
- '-d', distro, '-u', 'root', '--', 'bash', '-lc',
243
- 'source /opt/miniforge3/etc/profile.d/conda.sh && conda activate gapseq && gapseq -v',
244
- ])
245
- } catch {
246
- return { available: false, detail: 'WSL/gapseq 只读探测未完成。' }
247
- }
248
- const output = String(result?.stdout ?? '').slice(0, 512)
249
- if (!result?.ok || !/\bgapseq\b/i.test(output)) {
250
- return {
251
- available: false,
252
- detail: result?.timeout
253
- ? 'gapseq 探测超时(WSL 冷启动可能超过 30s),后台将自动重试。'
254
- : 'WSL/gapseq 只读探测未就绪。',
255
- }
256
- }
257
- const version = output.match(/gapseq(?:\s+version)?\s*[:v]?\s*([0-9][0-9.]*)/i)?.[1]
258
- return {
259
- available: true,
260
- detail: version ? `gapseq ${version}(WSL 只读探测通过)。` : 'gapseq(WSL 只读探测通过)。',
261
- }
262
- }
263
-
264
- function writeJson(res, status, body) {
265
- res.writeHead(status, {
266
- 'content-type': 'application/json; charset=utf-8',
267
- 'referrer-policy': 'no-referrer',
268
- })
269
- res.end(JSON.stringify(body))
270
- }
271
-
272
- /**
273
- * Loopback + same-origin guard copied from the host integration boundary.
274
- *
275
- * It independently verifies socket address, Host, browser cross-site intent,
276
- * and Origin when one is present. Loopback alone is not treated as a general
277
- * authorization mechanism for future write routes.
278
- */
279
- export function isLoopbackRequest(req) {
280
- const address = req.socket?.remoteAddress
281
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
282
- const host = req.headers?.host
283
- if (typeof host !== 'string') return false
284
- let hostUrl
285
- try {
286
- hostUrl = new URL(`http://${host}`)
287
- } catch {
288
- return false
289
- }
290
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
291
- if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
292
- const origin = req.headers?.origin
293
- if (origin === undefined) return true
294
- try {
295
- return new URL(origin).host === hostUrl.host
296
- } catch {
297
- return false
298
- }
299
- }
300
-
301
- function guardedGet(handler) {
302
- return async (req, res) => {
303
- if (!isLoopbackRequest(req)) {
304
- return writeJson(res, 403, {
305
- ok: false,
306
- code: 'loopback-required',
307
- message: 'loopback requests only',
308
- })
309
- }
310
- if (req.method !== 'GET') {
311
- return writeJson(res, 405, {
312
- ok: false,
313
- code: 'method-not-allowed',
314
- message: `method not allowed: ${req.method}`,
315
- })
316
- }
317
- try {
318
- const response = await handler()
319
- if (response?.ok !== true) {
320
- return writeJson(res, 500, {
321
- ok: false,
322
- code: 'internal',
323
- message: 'integration endpoint failed',
324
- })
325
- }
326
- return writeJson(res, 200, response)
327
- } catch {
328
- return writeJson(res, 500, {
329
- ok: false,
330
- code: 'internal',
331
- message: 'integration endpoint failed',
332
- })
333
- }
334
- }
335
- }
336
-
337
- /** Register the two fixed read-only integration routes when webServer exists. */
338
- export function registerIntegrationRoutes(ctx, options = {}) {
339
- const webServer = options.webServer ?? ctx?.webServer
340
- if (!webServer?.register) return () => {}
341
- const service = options.service ?? createIntegrationService(options)
342
- const routes = [
343
- {
344
- kind: 'exact',
345
- path: `${INTEGRATION_PREFIX}/health`,
346
- handler: guardedGet(() => service.health()),
347
- },
348
- {
349
- kind: 'exact',
350
- path: `${INTEGRATION_PREFIX}/v1/status`,
351
- handler: guardedGet(() => service.status()),
352
- },
353
- ]
354
- const disposers = routes.map((route) => webServer.register(route))
355
- return () => {
356
- for (const dispose of disposers) dispose?.()
357
- }
358
- }
359
-
360
- /**
361
- * Create the stateless portion of the integration API.
362
- *
363
- * Options exist solely to make protocol behavior testable without a dsh host;
364
- * production callers use the package defaults.
365
- */
366
- export function createIntegrationService(options = {}) {
367
- const pluginVersion = options.pluginVersion ?? PLUGIN_VERSION
368
- const dataRoot = options.dataRoot ?? defaultDataRoot()
369
- const now = options.now ?? Date.now
370
- const candidateProvider = options.pythonCandidates ?? pythonCandidates
371
- const fileExists = options.fileExists ?? existsSync
372
- const probeCobra = options.probeCobra ?? probeCobraVersion
373
- const probePython = options.probePython
374
- ?? (() => probePythonEnvironment(candidateProvider, fileExists, probeCobra))
375
- const probeGapseq = options.probeGapseq
376
- ?? (() => probeGapseqEnvironment({
377
- isWindows: options.isWindows ?? process.platform === 'win32',
378
- distro: options.gapseqDistro ?? process.env.GEM_GAPSEQ_DISTRO ?? 'Ubuntu-22.04',
379
- runner: options.runGapseqProbe ?? runGapseqVersionCommand,
380
- }))
381
- let cachedPython = null
382
- let cachedPythonAt = 0
383
- let pendingPython = null
384
- let cachedGapseq = null
385
- let cachedGapseqAt = 0
386
- let pendingGapseq = null
387
-
388
- /** Python 探测快(≈3s)且有 60s 缓存:保持同步等待,语义简单。 */
389
- function readPython() {
390
- const current = now()
391
- if (cachedPython && current - cachedPythonAt < RUNTIME_PROBE_CACHE_MS) return Promise.resolve(cachedPython)
392
- if (pendingPython) return pendingPython
393
- pendingPython = Promise.resolve()
394
- .then(probePython)
395
- .then((value) => {
396
- cachedPython = value
397
- cachedPythonAt = now()
398
- return value
399
- })
400
- .finally(() => { pendingPython = null })
401
- return pendingPython
402
- }
403
-
404
- /**
405
- * gapseq 探测**非阻塞**(stale-while-revalidate):缓存未过期直接返回;
406
- * 过期时立即返回旧值并后台刷新;从未探测过则返回 `available: null` 占位
407
- * (契约语义:null = 尚未探测,探测在后台进行,后续请求即得布尔结果)。
408
- * 这样 status 永远不会被 WSL 冷启动拖到消费端超时。
409
- */
410
- function readGapseq() {
411
- const current = now()
412
- // 失败结果(含超时)只缓存 60s,让后台尽快重试(WSL 冷启动是暂时性状态)。
413
- const ttl = cachedGapseq?.available === false ? 60_000 : GAPSEQ_PROBE_CACHE_MS
414
- if (cachedGapseq && current - cachedGapseqAt < ttl) return cachedGapseq
415
- if (!pendingGapseq) {
416
- pendingGapseq = Promise.resolve()
417
- .then(probeGapseq)
418
- .then((value) => {
419
- cachedGapseq = value
420
- cachedGapseqAt = now()
421
- return value
422
- })
423
- .catch(() => cachedGapseq)
424
- .finally(() => { pendingGapseq = null })
425
- }
426
- return cachedGapseq ?? {
427
- available: null,
428
- probing: true,
429
- detail: 'gapseq 只读探测进行中(WSL 冷启动可能需数秒),稍后刷新可见结果。',
430
- }
431
- }
432
-
433
- return {
434
- async health() {
435
- return {
436
- ok: true,
437
- value: {
438
- pluginId: PLUGIN_ID,
439
- pluginVersion,
440
- protocolMajor: PROTOCOL_MAJOR,
441
- protocolMinors: PROTOCOL_MINORS,
442
- features: INTEGRATION_FEATURES,
443
- },
444
- }
445
- },
446
-
447
- async status() {
448
- const python = await readPython()
449
- const gapseq = readGapseq()
450
- const carveme = carvemeStatus(dataRoot)
451
- const checks = [
452
- statusCheck(
453
- 'python.cobra',
454
- python.selected ? 'ok' : 'missing',
455
- python.selected
456
- ? `cobra ${python.selected.cobraVersion ?? 'available'} @ ${python.selected.path}`
457
- : '未找到可 import cobra 的 Python 解释器。',
458
- ),
459
- statusCheck(
460
- 'runtime.carveme',
461
- carveme.available ? 'ok' : 'missing',
462
- carveme.hint,
463
- ),
464
- statusCheck(
465
- 'runtime.gapseq',
466
- gapseq.available === true ? 'ok' : gapseq.available === null ? 'warn' : 'missing',
467
- gapseq.detail ?? (gapseq.available ? 'gapseq 只读探测通过。' : 'gapseq 只读探测未就绪。'),
468
- ),
469
- ]
470
- return {
471
- ok: true,
472
- value: {
473
- state: checks.every((check) => check.status === 'ok') ? 'ready' : 'degraded',
474
- generatedAt: new Date(now()).toISOString(),
475
- pluginVersion,
476
- features: INTEGRATION_FEATURES,
477
- checks,
478
- data: {
479
- models: boundedSummary(join(dataRoot, 'models'), (name) => name.endsWith('.xml')),
480
- ledger: ledgerSummary(dataRoot),
481
- exports: boundedSummary(join(dataRoot, 'exports')),
482
- },
483
- env: { python, engines: { carveme, gapseq } },
484
- remediations: remediationsFor(checks),
485
- },
486
- }
487
- },
488
- }
489
- }
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { pythonCandidates } from './python.js'
6
+ import { TOOLS_MANIFEST, buildCapabilitiesReport } from './capabilities.js'
7
+
8
+ /**
9
+ * dsh-bio-gem — hosted-domain integration protocol v1 (read-only batch).
10
+ *
11
+ * This module deliberately has no import-time probes: loading the plugin and
12
+ * serving /health must never spawn Python or modify the data directory.
13
+ */
14
+
15
+ export const INTEGRATION_PREFIX = '/api/dsh-bio-gem/integration'
16
+ export const PROTOCOL_MAJOR = 1
17
+ export const PROTOCOL_MINORS = [0]
18
+ export const RUNTIME_PROBE_CACHE_MS = 60_000
19
+ /** WSL/gapseq 探测更重(wsl.exe 冷启动可达十秒级),缓存更久且**非阻塞**。 */
20
+ export const GAPSEQ_PROBE_CACHE_MS = 300_000
21
+ export const INTEGRATION_FEATURES = [
22
+ 'status',
23
+ 'capabilities',
24
+ 'model-store',
25
+ 'ledger',
26
+ 'exports',
27
+ 'carveme-runtime',
28
+ 'gapseq-probe',
29
+ ]
30
+
31
+ const PLUGIN_ID = 'dsh-bio-gem'
32
+ // 版本号从 package.json 实时读,避免 bump 版本时漏改此处导致 /health 自报旧版本
33
+ // (第二真值源曾导致 galatea 报 0.1.2 而磁盘已是 0.1.3;重启无效、非缓存)。
34
+ const PLUGIN_VERSION = JSON.parse(
35
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
36
+ ).version
37
+
38
+ function defaultDataRoot() {
39
+ const dshHome = process.env.DSH_HOME ?? join(os.homedir(), '.dsh')
40
+ return join(dshHome, 'dsh-bio-gem')
41
+ }
42
+
43
+ function listFiles(dir, predicate = () => true) {
44
+ try {
45
+ return readdirSync(dir, { withFileTypes: true })
46
+ .filter((entry) => entry.isFile() && predicate(entry.name))
47
+ .map((entry) => {
48
+ const fullPath = join(dir, entry.name)
49
+ const stat = statSync(fullPath)
50
+ return {
51
+ name: entry.name,
52
+ sizeBytes: stat.size,
53
+ modifiedAt: stat.mtime.toISOString(),
54
+ }
55
+ })
56
+ .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
57
+ } catch {
58
+ return []
59
+ }
60
+ }
61
+
62
+ function boundedSummary(dir, predicate) {
63
+ const all = listFiles(dir, predicate)
64
+ return { count: all.length, items: all.slice(0, 50) }
65
+ }
66
+
67
+ function ledgerSummary(root) {
68
+ const dir = join(root, 'ledger')
69
+ const files = listFiles(dir, (name) => name.endsWith('.jsonl'))
70
+ const ledgers = files.map((file) => {
71
+ let entries = 0
72
+ try {
73
+ entries = readFileSync(join(dir, file.name), 'utf8').split(/\r?\n/).filter((line) => line.trim()).length
74
+ } catch { /* unreadable ledgers are represented as zero entries */ }
75
+ return {
76
+ model: file.name.replace(/\.jsonl$/, ''),
77
+ entries,
78
+ modifiedAt: file.modifiedAt,
79
+ }
80
+ })
81
+ return {
82
+ count: ledgers.length,
83
+ totalEntries: ledgers.reduce((total, ledger) => total + ledger.entries, 0),
84
+ dir,
85
+ ledgers: ledgers.slice(0, 50),
86
+ }
87
+ }
88
+
89
+ function carvemeStatus(root) {
90
+ const scripts = join(root, 'venv-carveme', 'Scripts')
91
+ const carve = existsSync(join(scripts, 'carve.exe'))
92
+ const diamond = existsSync(join(scripts, 'diamond.exe'))
93
+ return {
94
+ available: carve && diamond,
95
+ hint: carve && diamond
96
+ ? 'carve.exe 与 diamond.exe 均可读。'
97
+ : 'gem_build(carveme) 需要私有 venv 中的 carve.exe 与 diamond.exe。',
98
+ }
99
+ }
100
+
101
+ function statusCheck(id, status, detail) {
102
+ return { id, status, detail }
103
+ }
104
+
105
+ function remediationsFor(checks) {
106
+ const codes = new Set(checks.filter((check) => check.status !== 'ok').map((check) => check.id))
107
+ const remediations = []
108
+ if (codes.has('python.cobra')) {
109
+ remediations.push({
110
+ code: 'genie.bootstrap-python', owner: 'genie',
111
+ detail: '准备 BioGenie 共享 Python/cobra 环境后重新探测。',
112
+ })
113
+ }
114
+ if (codes.has('runtime.carveme')) {
115
+ remediations.push({
116
+ code: 'gem.install-carveme-runtime', owner: 'gem',
117
+ detail: '准备 gem 私有 CarveMe 运行时(含 diamond)后重新探测。',
118
+ })
119
+ }
120
+ if (codes.has('runtime.gapseq')) {
121
+ remediations.push({
122
+ code: 'genie.install-wsl-gapseq', owner: 'genie',
123
+ detail: '准备 WSL/gapseq 共享前置能力后重新探测。',
124
+ })
125
+ }
126
+ return remediations
127
+ }
128
+
129
+ /** Read only the installed cobra version; output and failures stay local. */
130
+ function probeCobraVersion(executable) {
131
+ return new Promise((resolve) => {
132
+ let settled = false
133
+ let timer = null
134
+ const finish = (value) => {
135
+ if (settled) return
136
+ settled = true
137
+ if (timer) clearTimeout(timer)
138
+ resolve(value)
139
+ }
140
+ try {
141
+ const child = spawn(executable, ['-I', '-c', 'import cobra; print(cobra.__version__)'], {
142
+ windowsHide: true,
143
+ stdio: ['ignore', 'pipe', 'ignore'],
144
+ })
145
+ let stdout = ''
146
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8') })
147
+ child.on('error', () => finish(null))
148
+ child.on('close', (code) => finish(code === 0 ? stdout.trim() || 'available' : null))
149
+ timer = setTimeout(() => {
150
+ try { child.kill() } catch { /* already exited */ }
151
+ finish(null)
152
+ }, 20_000)
153
+ } catch {
154
+ finish(null)
155
+ }
156
+ })
157
+ }
158
+
159
+ async function probePythonEnvironment(candidateProvider, fileExists, probeCobra) {
160
+ const candidates = candidateProvider().map((candidate) => ({
161
+ ...candidate,
162
+ exists: candidate.path === 'python' ? true : fileExists(candidate.path),
163
+ }))
164
+ let selected = null
165
+ for (const candidate of candidates) {
166
+ if (!candidate.exists) continue
167
+ let cobraVersion = null
168
+ try {
169
+ cobraVersion = await probeCobra(candidate.path)
170
+ } catch { /* individual candidate failures are an expected degraded state */ }
171
+ if (cobraVersion) {
172
+ selected = {
173
+ path: candidate.path,
174
+ source: candidate.source,
175
+ cobraVersion,
176
+ }
177
+ break
178
+ }
179
+ }
180
+ return {
181
+ selected,
182
+ candidates,
183
+ note: selected ? undefined : '所有候选均未通过 import cobra 的只读探测。',
184
+ }
185
+ }
186
+
187
+ /** Execute a fixed, read-only WSL command and retain only a tiny version response. */
188
+ function runGapseqVersionCommand(command, args) {
189
+ return new Promise((resolve) => {
190
+ let settled = false
191
+ let stdout = ''
192
+ let timer = null
193
+ const finish = (result) => {
194
+ if (settled) return
195
+ settled = true
196
+ if (timer) clearTimeout(timer)
197
+ resolve(result)
198
+ }
199
+ try {
200
+ // stdin 必须保持 pipe(并立即 end):实测 wsl.exe 在 stdin=ignore 下会极慢
201
+ // (同一条 `echo ok`:ignore 14.9s vs pipe 0.26s,约 50 倍),
202
+ // 这是之前 gapseq 探测频繁超时的真正根因。
203
+ const child = spawn(command, args, {
204
+ windowsHide: true,
205
+ stdio: ['pipe', 'pipe', 'ignore'],
206
+ })
207
+ child.stdin?.end()
208
+ child.stdout?.on('data', (chunk) => {
209
+ if (stdout.length < 512) stdout += chunk.toString('utf8').slice(0, 512 - stdout.length)
210
+ })
211
+ child.on('error', () => finish({ ok: false }))
212
+ child.on('close', (code) => finish({ ok: code === 0, stdout }))
213
+ timer = setTimeout(() => {
214
+ try { child.kill() } catch { /* already exited */ }
215
+ finish({ ok: false, timeout: true })
216
+ }, 30_000)
217
+ } catch {
218
+ finish({ ok: false })
219
+ }
220
+ })
221
+ }
222
+
223
+ async function probeGapseqEnvironment({ isWindows, distro, runner }) {
224
+ if (!isWindows) {
225
+ return { available: false, detail: 'gapseq 仅支持 Windows WSL 的只读探测。' }
226
+ }
227
+ // 快速预检:先确认目标发行版存在(wsl.exe -l -q 亚秒级),避免发行版缺失时
228
+ // 白等一次 bash 长命令直到超时刹车(真实运行时实测:本机 bash 启动即 ~3s)。
229
+ try {
230
+ const listed = await runner('wsl.exe', ['-l', '-q'])
231
+ const names = String(listed?.stdout ?? '')
232
+ .replace(/\u0000/g, '')
233
+ .split(/\r?\n/)
234
+ .map((name) => name.trim())
235
+ .filter(Boolean)
236
+ if (listed?.ok && !names.includes(distro)) {
237
+ return {
238
+ available: false,
239
+ detail: names.length > 0
240
+ ? `WSL 发行版 ${distro} 不存在(现有:${names.join(', ')})。`
241
+ : `WSL 未发现发行版(期望 ${distro})。`,
242
+ }
243
+ }
244
+ } catch { /* 预检失败不阻断后续只读探测 */ }
245
+ let result
246
+ try {
247
+ result = await runner('wsl.exe', [
248
+ '-d', distro, '-u', 'root', '--', 'bash', '-lc',
249
+ 'source /opt/miniforge3/etc/profile.d/conda.sh && conda activate gapseq && gapseq -v',
250
+ ])
251
+ } catch {
252
+ return { available: false, detail: 'WSL/gapseq 只读探测未完成。' }
253
+ }
254
+ const output = String(result?.stdout ?? '').slice(0, 512)
255
+ if (!result?.ok || !/\bgapseq\b/i.test(output)) {
256
+ return {
257
+ available: false,
258
+ detail: result?.timeout
259
+ ? 'gapseq 探测超时(WSL 冷启动可能超过 30s),后台将自动重试。'
260
+ : 'WSL/gapseq 只读探测未就绪。',
261
+ }
262
+ }
263
+ const version = output.match(/gapseq(?:\s+version)?\s*[:v]?\s*([0-9][0-9.]*)/i)?.[1]
264
+ return {
265
+ available: true,
266
+ detail: version ? `gapseq ${version}(WSL 只读探测通过)。` : 'gapseq(WSL 只读探测通过)。',
267
+ }
268
+ }
269
+
270
+ function writeJson(res, status, body) {
271
+ res.writeHead(status, {
272
+ 'content-type': 'application/json; charset=utf-8',
273
+ 'referrer-policy': 'no-referrer',
274
+ })
275
+ res.end(JSON.stringify(body))
276
+ }
277
+
278
+ /**
279
+ * Loopback + same-origin guard copied from the host integration boundary.
280
+ *
281
+ * It independently verifies socket address, Host, browser cross-site intent,
282
+ * and Origin when one is present. Loopback alone is not treated as a general
283
+ * authorization mechanism for future write routes.
284
+ */
285
+ export function isLoopbackRequest(req) {
286
+ const address = req.socket?.remoteAddress
287
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
288
+ const host = req.headers?.host
289
+ if (typeof host !== 'string') return false
290
+ let hostUrl
291
+ try {
292
+ hostUrl = new URL(`http://${host}`)
293
+ } catch {
294
+ return false
295
+ }
296
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
297
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
298
+ const origin = req.headers?.origin
299
+ if (origin === undefined) return true
300
+ try {
301
+ return new URL(origin).host === hostUrl.host
302
+ } catch {
303
+ return false
304
+ }
305
+ }
306
+
307
+ function guardedGet(handler) {
308
+ return async (req, res) => {
309
+ if (!isLoopbackRequest(req)) {
310
+ return writeJson(res, 403, {
311
+ ok: false,
312
+ code: 'loopback-required',
313
+ message: 'loopback requests only',
314
+ })
315
+ }
316
+ if (req.method !== 'GET') {
317
+ return writeJson(res, 405, {
318
+ ok: false,
319
+ code: 'method-not-allowed',
320
+ message: `method not allowed: ${req.method}`,
321
+ })
322
+ }
323
+ try {
324
+ const response = await handler()
325
+ if (response?.ok !== true) {
326
+ return writeJson(res, 500, {
327
+ ok: false,
328
+ code: 'internal',
329
+ message: 'integration endpoint failed',
330
+ })
331
+ }
332
+ return writeJson(res, 200, response)
333
+ } catch {
334
+ return writeJson(res, 500, {
335
+ ok: false,
336
+ code: 'internal',
337
+ message: 'integration endpoint failed',
338
+ })
339
+ }
340
+ }
341
+ }
342
+
343
+ /** Register the two fixed read-only integration routes when webServer exists. */
344
+ export function registerIntegrationRoutes(ctx, options = {}) {
345
+ const webServer = options.webServer ?? ctx?.webServer
346
+ if (!webServer?.register) return () => {}
347
+ const service = options.service ?? createIntegrationService(options)
348
+ const routes = [
349
+ {
350
+ kind: 'exact',
351
+ path: `${INTEGRATION_PREFIX}/health`,
352
+ handler: guardedGet(() => service.health()),
353
+ },
354
+ {
355
+ kind: 'exact',
356
+ path: `${INTEGRATION_PREFIX}/v1/status`,
357
+ handler: guardedGet(() => service.status()),
358
+ },
359
+ {
360
+ kind: 'exact',
361
+ path: `${INTEGRATION_PREFIX}/v1/capabilities`,
362
+ handler: guardedGet(() => service.capabilities()),
363
+ },
364
+ ]
365
+ const disposers = routes.map((route) => webServer.register(route))
366
+ return () => {
367
+ for (const dispose of disposers) dispose?.()
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Create the stateless portion of the integration API.
373
+ *
374
+ * Options exist solely to make protocol behavior testable without a dsh host;
375
+ * production callers use the package defaults.
376
+ */
377
+ export function createIntegrationService(options = {}) {
378
+ const pluginVersion = options.pluginVersion ?? PLUGIN_VERSION
379
+ const dataRoot = options.dataRoot ?? defaultDataRoot()
380
+ const now = options.now ?? Date.now
381
+ const candidateProvider = options.pythonCandidates ?? pythonCandidates
382
+ const fileExists = options.fileExists ?? existsSync
383
+ const probeCobra = options.probeCobra ?? probeCobraVersion
384
+ const probePython = options.probePython
385
+ ?? (() => probePythonEnvironment(candidateProvider, fileExists, probeCobra))
386
+ const probeGapseq = options.probeGapseq
387
+ ?? (() => probeGapseqEnvironment({
388
+ isWindows: options.isWindows ?? process.platform === 'win32',
389
+ distro: options.gapseqDistro ?? process.env.GEM_GAPSEQ_DISTRO ?? 'Ubuntu-22.04',
390
+ runner: options.runGapseqProbe ?? runGapseqVersionCommand,
391
+ }))
392
+ let cachedPython = null
393
+ let cachedPythonAt = 0
394
+ let pendingPython = null
395
+ let cachedGapseq = null
396
+ let cachedGapseqAt = 0
397
+ let pendingGapseq = null
398
+
399
+ /** Python 探测快(≈3s)且有 60s 缓存:保持同步等待,语义简单。 */
400
+ function readPython() {
401
+ const current = now()
402
+ if (cachedPython && current - cachedPythonAt < RUNTIME_PROBE_CACHE_MS) return Promise.resolve(cachedPython)
403
+ if (pendingPython) return pendingPython
404
+ pendingPython = Promise.resolve()
405
+ .then(probePython)
406
+ .then((value) => {
407
+ cachedPython = value
408
+ cachedPythonAt = now()
409
+ return value
410
+ })
411
+ .finally(() => { pendingPython = null })
412
+ return pendingPython
413
+ }
414
+
415
+ /**
416
+ * gapseq 探测**非阻塞**(stale-while-revalidate):缓存未过期直接返回;
417
+ * 过期时立即返回旧值并后台刷新;从未探测过则返回 `available: null` 占位
418
+ * (契约语义:null = 尚未探测,探测在后台进行,后续请求即得布尔结果)。
419
+ * 这样 status 永远不会被 WSL 冷启动拖到消费端超时。
420
+ */
421
+ function readGapseq() {
422
+ const current = now()
423
+ // 失败结果(含超时)只缓存 60s,让后台尽快重试(WSL 冷启动是暂时性状态)。
424
+ const ttl = cachedGapseq?.available === false ? 60_000 : GAPSEQ_PROBE_CACHE_MS
425
+ if (cachedGapseq && current - cachedGapseqAt < ttl) return cachedGapseq
426
+ if (!pendingGapseq) {
427
+ pendingGapseq = Promise.resolve()
428
+ .then(probeGapseq)
429
+ .then((value) => {
430
+ cachedGapseq = value
431
+ cachedGapseqAt = now()
432
+ return value
433
+ })
434
+ .catch(() => cachedGapseq)
435
+ .finally(() => { pendingGapseq = null })
436
+ }
437
+ return cachedGapseq ?? {
438
+ available: null,
439
+ probing: true,
440
+ detail: 'gapseq 只读探测进行中(WSL 冷启动可能需数秒),稍后刷新可见结果。',
441
+ }
442
+ }
443
+
444
+ /** 依赖检查(status 与 capabilities 共用;语义与既有 status 一致)。 */
445
+ async function collectChecks() {
446
+ const python = await readPython()
447
+ const gapseq = readGapseq()
448
+ const carveme = carvemeStatus(dataRoot)
449
+ const checks = [
450
+ statusCheck(
451
+ 'python.cobra',
452
+ python.selected ? 'ok' : 'missing',
453
+ python.selected
454
+ ? `cobra ${python.selected.cobraVersion ?? 'available'} @ ${python.selected.path}`
455
+ : '未找到可 import cobra 的 Python 解释器。',
456
+ ),
457
+ statusCheck(
458
+ 'runtime.carveme',
459
+ carveme.available ? 'ok' : 'missing',
460
+ carveme.hint,
461
+ ),
462
+ statusCheck(
463
+ 'runtime.gapseq',
464
+ gapseq.available === true ? 'ok' : gapseq.available === null ? 'warn' : 'missing',
465
+ gapseq.detail ?? (gapseq.available ? 'gapseq 只读探测通过。' : 'gapseq 只读探测未就绪。'),
466
+ ),
467
+ ]
468
+ return { python, gapseq, carveme, checks }
469
+ }
470
+
471
+ return {
472
+ async health() {
473
+ return {
474
+ ok: true,
475
+ value: {
476
+ pluginId: PLUGIN_ID,
477
+ pluginVersion,
478
+ protocolMajor: PROTOCOL_MAJOR,
479
+ protocolMinors: PROTOCOL_MINORS,
480
+ features: INTEGRATION_FEATURES,
481
+ },
482
+ }
483
+ },
484
+
485
+ async capabilities() {
486
+ const { checks } = await collectChecks()
487
+ return { ok: true, value: buildCapabilitiesReport({ pluginVersion, checks }) }
488
+ },
489
+
490
+ async status() {
491
+ const { python, gapseq, carveme, checks } = await collectChecks()
492
+ return {
493
+ ok: true,
494
+ value: {
495
+ state: checks.every((check) => check.status === 'ok') ? 'ready' : 'degraded',
496
+ generatedAt: new Date(now()).toISOString(),
497
+ pluginVersion,
498
+ features: INTEGRATION_FEATURES,
499
+ checks,
500
+ data: {
501
+ models: boundedSummary(join(dataRoot, 'models'), (name) => name.endsWith('.xml')),
502
+ ledger: ledgerSummary(dataRoot),
503
+ exports: boundedSummary(join(dataRoot, 'exports')),
504
+ },
505
+ env: { python, engines: { carveme, gapseq } },
506
+ remediations: remediationsFor(checks),
507
+ },
508
+ }
509
+ },
510
+ }
511
+ }