@dsh-bio/dsh-bio-gem 0.1.13 → 0.1.15

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