@dsh-bio/dsh-bio-gem 0.1.3 → 0.1.12
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/README.md +236 -199
- package/docs/ARCHITECTURE.md +142 -113
- package/docs/releases/v0.1.12.md +43 -0
- package/package.json +5 -3
- package/python/coherence.py +159 -0
- package/python/gapfind.py +413 -396
- package/python/gem_ops.py +19 -0
- package/python/precursor_scan.py +127 -0
- package/python/validate.py +435 -409
- package/skills/gem-expert.md +1 -1
- package/src/index.js +21 -2
- package/src/integration.js +489 -0
- package/src/jobs.js +5 -21
- package/src/python.js +113 -17
- package/src/tools.js +27 -8
package/skills/gem-expert.md
CHANGED
|
@@ -12,7 +12,7 @@ language: mixed
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| 已有 SBML 模型文件,想知道概要(基因/反应/复制子)| `gem_report`(model 参数=绝对路径)|
|
|
14
14
|
| 验证模型质量(五道关卡:加载/元素平衡/生长真实性/表型/必需性抽检)| `gem_validate`(model + medium)|
|
|
15
|
-
| 模型在目标培养基不长,想知道为什么 | `gem_gapfind`(model + medium + substrates
|
|
15
|
+
| 模型在目标培养基不长,想知道为什么 | `gem_gapfind`(model + medium + substrates)**;先跑 `gem_precursor_scan` 确认阻塞层次**——它报「卡在哪个前体」(可生长即返无阻塞),比缺口清单更贴近根因 |
|
|
16
16
|
| gapfind 判 L3(内部路径)后自动补洞(白名单/MILP)| `gem_l3_fix`(model + medium + substrates;allow_math=true 才放数学连接;补后自动跑 G6 防能量循环)|
|
|
17
17
|
| 看/改 biomass(FBA 目标函数)| `gem_biomass`(action=inspect 只读组分/对照参考;apply 显式 profile + 三联对照,原文件不动可回滚)|
|
|
18
18
|
| 按缺口自动补洞(缺交换/转运规则修复)| `gem_gapfill`(model + medium)→ 补完重跑 `gem_validate` |
|
package/src/index.js
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
// 注入 tools(5 语义化工具:gem_report/validate/gapfind/gapfill/build)+ skills(gem-expert)。
|
|
3
3
|
import { registerTools } from './tools.js'
|
|
4
4
|
import { registerSkills } from './skills.js'
|
|
5
|
+
import { registerIntegrationRoutes, createIntegrationService } from './integration.js'
|
|
5
6
|
|
|
6
7
|
/** Cordis 插件名(cordis.patch.yml row id 同名)。 */
|
|
7
8
|
export const name = 'dsh-bio-gem'
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* 静态注入只列**必选**服务(数组形式是 cordis 唯一支持的「服务名列表」写法;
|
|
12
|
+
* `{ required, optional }` 的对象形式会被 cordis 当成「服务名 → 配置」字典,
|
|
13
|
+
* 导致插件永远 pending —— 实测:dsh boot 报
|
|
14
|
+
* `@dsh-bio/dsh-bio-gem: pending (waiting for services: required, optional)`)。
|
|
15
|
+
*
|
|
16
|
+
* `webServer` 是**可选**服务(非 web 部署不提供),改用 apply 内的动态注入
|
|
17
|
+
* `ctx.inject(['webServer'], cb)`(官方 dsh 插件同款模式):服务可用时注册
|
|
18
|
+
* 只读 integration 路由,不可用时 21 个工具与 skill 照常注册。
|
|
19
|
+
*/
|
|
10
20
|
export const inject = ['tools', 'skills']
|
|
11
21
|
|
|
12
22
|
/**
|
|
@@ -16,4 +26,13 @@ export const inject = ['tools', 'skills']
|
|
|
16
26
|
export function apply(ctx) {
|
|
17
27
|
registerTools(ctx)
|
|
18
28
|
registerSkills(ctx)
|
|
19
|
-
|
|
29
|
+
|
|
30
|
+
const service = createIntegrationService()
|
|
31
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
32
|
+
webCtx.effect(() => registerIntegrationRoutes(webCtx, { service }), 'dsh-bio-gem: integration API routes')
|
|
33
|
+
// 延迟预热(**不在插件加载期**执行,不阻塞启动):启动 ~8s 后后台跑一次只读探测,
|
|
34
|
+
// 让面板首次打开即命中 60s 缓存,避免首次冷探测(含 WSL 探测)被消费端超时截断。
|
|
35
|
+
const warmup = setTimeout(() => { void service.status().catch(() => {}) }, 8_000)
|
|
36
|
+
webCtx.effect(() => () => clearTimeout(warmup), 'dsh-bio-gem: runtime probe warm-up')
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
package/src/jobs.js
CHANGED
|
@@ -6,30 +6,14 @@ import { spawn } from 'node:child_process'
|
|
|
6
6
|
import fs from 'node:fs'
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
|
|
9
|
+
// 解释器选择与 PYTHON_DIR 统一到 python.js:本文件此前另有一份硬编码 miniconda
|
|
10
|
+
// 路径的 pythonExe 副本,会造成「gem_build 与其余 op 使用不同解释器」的隐性分裂
|
|
11
|
+
// (2026-09-11 统一;候选顺序见 python.js:GEM_PYTHON → 宿主 dsh-bio-genie
|
|
12
|
+
// 自举环境 → CONDA_PREFIX → PATH,且逐个探测 import cobra)。
|
|
13
|
+
import { pythonExe, PYTHON_DIR } from './python.js'
|
|
10
14
|
|
|
11
15
|
const ROOT = path.join(os.homedir(), '.dsh', 'dsh-bio-gem')
|
|
12
16
|
const JOBS_DIR = path.join(ROOT, 'jobs')
|
|
13
|
-
// 阶段D-E2E 修复:new URL(...).pathname 在 Windows 产生 "/C:/..." 前导斜杠,
|
|
14
|
-
// path.join 后得 "\C:\...python"(不存在)→ spawn ENOENT → gem_build 恒 "result missing"。
|
|
15
|
-
// 与 python.js 同款写法:fileURLToPath + dirname。
|
|
16
|
-
const PYTHON_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'python')
|
|
17
|
-
|
|
18
|
-
// 运行时探测:优先 miniconda(本机分析环境,cobra 已装),回退 env GEM_PYTHON / PATH
|
|
19
|
-
export function pythonExe() {
|
|
20
|
-
const cands = [
|
|
21
|
-
process.env.GEM_PYTHON,
|
|
22
|
-
'C:/Users/shuai/miniconda3/python.exe',
|
|
23
|
-
'python',
|
|
24
|
-
]
|
|
25
|
-
for (const c of cands) {
|
|
26
|
-
if (!c) continue
|
|
27
|
-
try {
|
|
28
|
-
if (c === 'python' || fs.existsSync(c)) return c
|
|
29
|
-
} catch { /* ignore */ }
|
|
30
|
-
}
|
|
31
|
-
return 'python'
|
|
32
|
-
}
|
|
33
17
|
|
|
34
18
|
const jobs = new Map() // jobId -> {cp, jobDir, ...}
|
|
35
19
|
|