From a2e8cbae86d7df629646b41b2044f40932baf6b5 Mon Sep 17 00:00:00 2001 From: potaser Date: Wed, 19 Aug 2026 20:17:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(lightweight-extension):=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E6=BF=80=E6=B4=BB=E3=80=81=E5=89=8D=E7=BC=80=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E3=80=81=E6=95=B0=E6=8D=AE=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.py: _make_env 用 conda run 激活环境(执行 activate.d,补 PYTHONPATH) - index_ml.html: 环境下拉框动态加载 + 补传 runtime_env - runtime_binding.py: 前缀误判修复、放宽 ambiguous、can_rename 判断、子目录数据复制补 mkdir - api.py: 缺输出目录时友好提示 - main.py + lightWeightExtensionTest.js: run.sh 环境声明 # lightweight-env - index.html/index_ml.html: 拖拽上传进度条 Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/vue3/index.html | 57 ++++++++- apps/web/vue3/index_ml.html | 80 ++++++++++++- apps/web/vue3/lightWeightExtensionTest.js | 24 +++- apps/web/vue3/main.py | 19 +++ gfa_core/extension_stage_lightweight/api.py | 19 ++- .../runtime_binding.py | 109 +++++++++++------- .../extension_stage_lightweight/service.py | 32 +++++ 7 files changed, 286 insertions(+), 54 deletions(-) diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index f2c7b5618..a72af82be 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -1422,6 +1422,12 @@ placeholder="大文件可直接引用;每行填写一个绝对路径"> +
+ +
+ {{ lightweightUploadProgress.label }} · {{ formatBytes(lightweightUploadProgress.loaded) }} / {{ formatBytes(lightweightUploadProgress.total) }} · {{ formatBytes(lightweightUploadProgress.speed) }}/s +
+
{ + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const v = bytes / Math.pow(1024, i); + return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`; + }; const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`; const extensionUploadPath = (file) => { const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, ''); @@ -19623,6 +19644,36 @@ compare: [[flesh, peel]]`; return port ? `${window.location.protocol}//${host}:${port}${value}` : `${window.location.protocol}//${host}${value}`; }; const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options); + const uploadExtensionWithProgress = (path, body, label, headers = {}) => { + let lastLoaded = 0, lastTime = Date.now(); + const p = lightweightUploadProgress; + p.active = true; p.percent = 0; p.loaded = 0; p.total = 0; p.speed = 0; p.label = label; + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', extensionApiUrl(path)); + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const now = Date.now(), dt = now - lastTime; + if (dt >= 200) { p.speed = (e.loaded - lastLoaded) / (dt / 1000); lastLoaded = e.loaded; lastTime = now; } + p.loaded = e.loaded; p.total = e.total; + p.percent = e.total > 0 ? Math.min(100, Math.round((e.loaded / e.total) * 100)) : 0; + }; + xhr.onload = () => { + p.active = false; + resolve({ + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + statusText: xhr.statusText, + json: () => Promise.resolve().then(() => JSON.parse(xhr.responseText)), + text: () => Promise.resolve(xhr.responseText), + }); + }; + xhr.onerror = () => { p.active = false; reject(new Error('上传失败:网络错误')); }; + xhr.onabort = () => { p.active = false; reject(new Error('上传已取消')); }; + xhr.send(body); + }); + }; const pushExtensionStatus = (message, type = 'info', showDialog = false) => { const text = String(message || ''); extensionBuild.statusText = text; @@ -19985,7 +20036,7 @@ compare: [[flesh, peel]]`; extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file))); if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths); form.append('runtime_env', extensionBuild.runtimeEnv); - const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form }); + const response = await uploadExtensionWithProgress('/api/lightweight-extension-stages/upload', form, '正在上传拓展包'); if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败')); ElMessage.success('文件上传成功,已开始生成并验收'); rememberLightweightOperation(extensionId, 'upload', token, card.card_id); @@ -23805,6 +23856,7 @@ compare: [[flesh, peel]]`; uploadedFiles, extensionBuild, extensionEnvOptions, + lightweightUploadProgress, extensionStages, extensionCompletedStages, lightweightExtensionConversation, @@ -23821,6 +23873,7 @@ compare: [[flesh, peel]]`; cancelExtensionRunParameters, confirmExtensionRunParameters, extensionFileKey, + formatBytes, onExtensionCodeFilesChange, onExtensionDataFilesChange, handleLightweightExtensionFolderDrop, @@ -23971,6 +24024,7 @@ compare: [[flesh, peel]]`; appendExtensionFiles, buildLightweightExtensionStage, lightweightExtensionTestStatus, + uploadExtensionWithProgress, }, event), handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({ jobInfo, @@ -23979,6 +24033,7 @@ compare: [[flesh, peel]]`; appendExtensionFiles, buildLightweightExtensionStage, lightweightExtensionTestStatus, + uploadExtensionWithProgress, }, event), openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(), // Working DirectorySelect diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index 8f2cd4ec7..6c2d60dab 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -1397,10 +1397,8 @@ {{ t('名称') }} {{ t('原始命令') }} {{ t('环境') }} - - - - + + {{ t('原始代码') }} @@ -1426,6 +1424,12 @@ placeholder="大文件可直接引用;每行填写一个绝对路径">
+
+ +
+ {{ lightweightUploadProgress.label }} · {{ formatBytes(lightweightUploadProgress.loaded) }} / {{ formatBytes(lightweightUploadProgress.total) }} · {{ formatBytes(lightweightUploadProgress.speed) }}/s +
+
{ + try { + const response = await extensionApiFetch('/api/extension-stages/envs'); + if (!response.ok) return; + const data = await response.json(); + if (data.success && Array.isArray(data.envs) && data.envs.length) { + extensionEnvOptions.value = data.envs; + } + } catch (err) { + console.warn('[extension env options] load failed', err); + } + }; const extensionRuns = ref([]); const extensionResultDialogVisible = ref(false); const extensionRun = reactive({ @@ -19325,6 +19350,13 @@ compare: [[flesh, peel]]`; ElMessage.error('上传失败: ' + err.message); }; + const formatBytes = (bytes) => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const v = bytes / Math.pow(1024, i); + return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`; + }; const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`; const extensionUploadPath = (file) => { const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, ''); @@ -19439,6 +19471,36 @@ compare: [[flesh, peel]]`; return `${window.location.protocol}//${host}:${window.location.port}${value}`; }; const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options); + const uploadExtensionWithProgress = (path, body, label, headers = {}) => { + let lastLoaded = 0, lastTime = Date.now(); + const p = lightweightUploadProgress; + p.active = true; p.percent = 0; p.loaded = 0; p.total = 0; p.speed = 0; p.label = label; + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', extensionApiUrl(path)); + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const now = Date.now(), dt = now - lastTime; + if (dt >= 200) { p.speed = (e.loaded - lastLoaded) / (dt / 1000); lastLoaded = e.loaded; lastTime = now; } + p.loaded = e.loaded; p.total = e.total; + p.percent = e.total > 0 ? Math.min(100, Math.round((e.loaded / e.total) * 100)) : 0; + }; + xhr.onload = () => { + p.active = false; + resolve({ + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + statusText: xhr.statusText, + json: () => Promise.resolve().then(() => JSON.parse(xhr.responseText)), + text: () => Promise.resolve(xhr.responseText), + }); + }; + xhr.onerror = () => { p.active = false; reject(new Error('上传失败:网络错误')); }; + xhr.onabort = () => { p.active = false; reject(new Error('上传已取消')); }; + xhr.send(body); + }); + }; const pushExtensionStatus = (message, type = 'info', showDialog = false) => { const text = String(message || ''); extensionBuild.statusText = text; @@ -19800,7 +19862,8 @@ compare: [[flesh, peel]]`; extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file, extensionUploadPath(file))); extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file))); if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths); - const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form }); + form.append('runtime_env', extensionBuild.runtimeEnv); + const response = await uploadExtensionWithProgress('/api/lightweight-extension-stages/upload', form, '正在上传拓展包'); if (!response.ok) throw new Error(await extensionApiError(response, t('原始包可复现检查提交失败'))); ElMessage.success(t('文件上传成功,已开始生成并验收')); rememberLightweightOperation(extensionId, 'upload', token, card.card_id); @@ -23225,6 +23288,7 @@ compare: [[flesh, peel]]`; startAgentTaskPolling(); startRuntimeMonitor(); loadExtensionStages(); + loadExtensionEnvOptions(); loadLightweightExtensionStages(); resumeLightweightOperation(); loadExtensionRuns(); @@ -23556,6 +23620,8 @@ compare: [[flesh, peel]]`; intentHistory, uploadedFiles, extensionBuild, + extensionEnvOptions, + lightweightUploadProgress, extensionStages, extensionCompletedStages, lightweightExtensionConversation, @@ -23572,6 +23638,7 @@ compare: [[flesh, peel]]`; cancelExtensionRunParameters, confirmExtensionRunParameters, extensionFileKey, + formatBytes, onExtensionCodeFilesChange, onExtensionDataFilesChange, removeExtensionFile, @@ -23593,6 +23660,7 @@ compare: [[flesh, peel]]`; validateExtensionStage, publishExtensionStage, loadExtensionStages, + loadExtensionEnvOptions, loadExtensionRuns, runExtensionStage, runExtensionStageFromIntent, @@ -23607,6 +23675,7 @@ compare: [[flesh, peel]]`; appendExtensionFiles, buildLightweightExtensionStage, lightweightExtensionTestStatus, + uploadExtensionWithProgress, }, event), handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({ jobInfo, @@ -23615,6 +23684,7 @@ compare: [[flesh, peel]]`; appendExtensionFiles, buildLightweightExtensionStage, lightweightExtensionTestStatus, + uploadExtensionWithProgress, }, event), openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(), methodologyPaperId, diff --git a/apps/web/vue3/lightWeightExtensionTest.js b/apps/web/vue3/lightWeightExtensionTest.js index f93af52ca..ba835f95d 100644 --- a/apps/web/vue3/lightWeightExtensionTest.js +++ b/apps/web/vue3/lightWeightExtensionTest.js @@ -124,6 +124,17 @@ }; } + async function extractEnvDeclaration(files) { + for (const file of files) { + const path = normalizedRelativePath(file).toLowerCase(); + if (!path.endsWith('.sh')) continue; + const content = await file.text(); + const m = content.match(/^\s*#\s*lightweight-env\s*:\s*([A-Za-z0-9_.-]+)/m); + if (m) return m[1]; + } + return null; + } + async function encodeBase64(file) { const buffer = await file.arrayBuffer(); const bytes = new Uint8Array(buffer); @@ -146,11 +157,12 @@ payload.files.push(await encodeBase64(file)); payload.relative_paths.push(normalizedRelativePath(file)); } - const response = await fetch('/api/job/import-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); + const response = await hooks.uploadExtensionWithProgress( + '/api/job/import-folder', + JSON.stringify(payload), + '正在镜像导入 Job', + { 'Content-Type': 'application/json' }, + ); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.detail || data.error || data.message || 'Job 导入失败'); @@ -160,6 +172,8 @@ async function resolveRuntimeEnv(files) { const dependencies = await extractDependencies(files); + const envDeclaration = await extractEnvDeclaration(files); + if (envDeclaration) dependencies.env_declaration = envDeclaration; console.log('[lightWeightExtensionTest] 提取的依赖:', JSON.stringify(dependencies), '| 扫描文件数:', files.length); try { const response = await fetch('/api/extension-stages/envs/resolve', { diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 3af4b63ae..0c5d90f6a 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -8919,6 +8919,7 @@ class ExtensionStageResolveIntentRequest(BaseModel): class ExtensionStageEnvResolveRequest(BaseModel): python_imports: List[str] = Field(default_factory=list) r_packages: List[str] = Field(default_factory=list) + env_declaration: Optional[str] = None @@ -9015,6 +9016,24 @@ async def _r_packages_for_env(env_name: str) -> set[str]: @app.post("/api/extension-stages/envs/resolve") async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest): + # 工具显式声明的环境优先(run.sh 里 # lightweight-env: ) + if req.env_declaration: + env = req.env_declaration.strip() + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", env, "python", "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + if proc.returncode == 0: + return { + "success": True, + "runtime_env": env, + "reason": "explicit_env_declaration", + "dependency": env, + "fallback": False, + } + python_imports = _normalized_dependency_names(req.python_imports) r_packages = _normalized_dependency_names(req.r_packages) if not python_imports and not r_packages: diff --git a/gfa_core/extension_stage_lightweight/api.py b/gfa_core/extension_stage_lightweight/api.py index 54927cdce..0bb29a5da 100644 --- a/gfa_core/extension_stage_lightweight/api.py +++ b/gfa_core/extension_stage_lightweight/api.py @@ -278,12 +278,29 @@ def _operation_run(record: dict[str, Any], operation: dict[str, Any]) -> tuple[d return None, "runtime" if action == "run" else "validation" +def _missing_output_dir_hint(record: dict[str, Any], token: str) -> str | None: + """当 stderr 提示输出目录不存在时,给出更明确的提示。""" + for log_path in _logs_for(record, token): + try: + text = Path(log_path).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if "No such file or directory" in text and "outputs/" in text: + return ( + "输出目录不存在:工具把输出写入 outputs/ 子目录但未创建该目录" + "(hmmsearch/diamond 等不会自动建目录),请在 run.sh 开头添加 mkdir -p 对应目录" + ) + return None + + def _run_error(record: dict[str, Any], token: str, run: dict[str, Any], phase: str) -> dict[str, Any]: details = run.get("error") if isinstance(run.get("error"), dict) else {} binding = run.get("binding") if isinstance(run.get("binding"), dict) else {} binding_error = binding.get("error") if isinstance(binding.get("error"), dict) else {} + hint = _missing_output_dir_hint(record, token) message = ( - details.get("message") + hint + or details.get("message") or binding_error.get("message") or ("runtime binding was not resolved" if binding.get("status") != "resolved" else "execution did not satisfy the output contract") ) diff --git a/gfa_core/extension_stage_lightweight/runtime_binding.py b/gfa_core/extension_stage_lightweight/runtime_binding.py index 0386f2937..595996f95 100644 --- a/gfa_core/extension_stage_lightweight/runtime_binding.py +++ b/gfa_core/extension_stage_lightweight/runtime_binding.py @@ -349,15 +349,14 @@ def _locate_patch_targets( for anchor in anchors } if reason is None: - if len(source_matches) > 1 or len(anchor_identities) != len(anchors): - reason = "source_literal_ambiguous" - elif ( - len(source_matches) == 1 - and source_matches[0][1] != len(anchors) - ): - reason = "source_literal_ambiguous" - elif not source_matches: + if not source_matches: reason = "source_literal_not_found_in_raw_shell" + elif len(source_matches) > 1: + # 字面量跨多个脚本,无法唯一定位 + reason = "source_literal_ambiguous" + elif len(anchors) != 1 or len(anchor_identities) != 1: + # 需要唯一 anchor 才能安全 patch + reason = "source_literal_ambiguous" clean_candidate = { key: value @@ -782,9 +781,21 @@ def _shared_output_prefix(outputs: list[dict[str, Any]]) -> str | None: _DIR_OVERRIDE_SWITCHES = ("-oc", "-o") # tools that accept a writable output directory -def _is_dir_prefix(prefix: str) -> bool: - """Return True when *prefix* looks like a directory rather than a filename stem.""" - return "/" not in prefix and "." not in prefix and _PREFIX_TOKEN.fullmatch(prefix) is not None +def _prefix_targets_directory(paths: list[str], prefix: str) -> bool: + """Return True when *prefix* matches a directory component of any path. + + Checks the actual output paths rather than guessing from the prefix string: + ``meme_out/logo1.png`` has ``meme_out`` as a directory component, while + ``input_qc.tsv`` and ``Ft.blast`` do not — so filename stems are not mistaken + for directory prefixes. + """ + for path in paths: + for part in PurePosixPath(path).parts[:-1]: # 排除文件名 + if part.startswith(prefix) and ( + len(part) == len(prefix) or part[len(prefix)] in _PREFIX_DELIMITERS + ): + return True + return False def _replace_positional_prefix(command: str, old: str, new: str) -> str: @@ -1066,7 +1077,16 @@ def resolve_runtime_bindings( for fact_id in target.get("fact_ids", []) if isinstance(fact_id, str) } + unpatchable_fact_ids = { + fact_id + for target in contract.get("unpatchable_targets", []) + if isinstance(target, dict) + for fact_id in target.get("fact_ids", []) + if isinstance(fact_id, str) + } output_bindings = [] + output_paths = [output["path"] for output in output_files] + prefix_targets_dir = _prefix_targets_directory(output_paths, old_prefix) for output in output_files: original_path = output["path"] runtime_path = _runtime_input_owned_output( @@ -1075,9 +1095,17 @@ def resolve_runtime_bindings( allow_sidecar=output["fact_id"] not in direct_output_fact_ids, ) if runtime_path is None: + # 可重命名当且仅当:input-derived 前缀 / 目录前缀 / 有可 patch 的 + # 输出字面量。只有三者都不满足(硬编码且无法 patch,如 + # input_qc.tsv 动态拼接)才保持原名。 + can_rename = ( + bool(participating_paths) + or prefix_targets_dir + or output["fact_id"] not in unpatchable_fact_ids + ) runtime_path = ( _replace_output_prefix(original_path, old_prefix, new_prefix) - if _output_has_prefix(original_path, old_prefix) + if can_rename and _output_has_prefix(original_path, old_prefix) else original_path ) output_bindings.append( @@ -1781,43 +1809,39 @@ def render_runtime_package( ): old = prefix["old_prefix"] new = prefix["new_prefix"] - # Only rewrite when the prefix looks like a directory being renamed. - # Check: every output whose runtime path differs has the prefix in - # a directory component (not the filename). When that holds the - # tool is writing into a fixed directory name and we can inject a - # post-command rename. if any( b.get("transformed") for b in resolved.get("output_bindings", []) ): - # Prepend an output-dir override when the tool supports - # ``-oc`` (MEME family) and the old prefix is a plain - # single-segment directory. - if _is_dir_prefix(old): + # 目录前缀(如 meme_out/logo1.png 的 meme_out)→ -oc 注入。 + # 文件名 stem(如 input_qc.tsv)不是目录,不能注入 -oc。 + transformed_paths = [ + b["original_path"] + for b in resolved.get("output_bindings", []) + if b.get("transformed") + ] + is_dir_prefix = _prefix_targets_directory(transformed_paths, old) + if is_dir_prefix: actual_command = _inject_dir_prefix_override( command, old, new ) - # Also rewrite the inner command inside wrapper scripts - # (e.g. ``bash run.sh`` wraps ``MCScanX ./Ft``). - # Try positional-prefix replacement (for tools that take - # the output prefix as a positional argument), then -oc/-o - # injection (for MEME-family tools). - for script in sorted(source_hashes): - script_path = raw_code / script - inner = script_path.read_text( - encoding="utf-8", errors="replace" + # 改写 shell 脚本里的命令(位置参数替换 / -oc 注入)。 + # 只处理 .sh 脚本:代码文件(.py 等)的输出路径由 + # _render_script_updates 负责,shlex 会破坏它们的语法。 + for script in sorted(source_hashes): + if not script.endswith(".sh"): + continue + script_path = raw_code / script + inner = script_path.read_text( + encoding="utf-8", errors="replace" + ) + rewritten = _replace_positional_prefix(inner, old, new) + if rewritten == inner and is_dir_prefix: + rewritten = _inject_dir_prefix_override(inner, old, new) + if rewritten != inner: + source_script_updates[script] = rewritten + _symlink_prefix_data_files( + old, new, work_dir, set(source_hashes) ) - rewritten = _replace_positional_prefix(inner, old, new) - if rewritten == inner: - rewritten = _inject_dir_prefix_override(inner, old, new) - if rewritten != inner: - source_script_updates[script] = rewritten - # When positional-prefix rewrite changes a tool command - # (e.g. MCScanX ./Ft → MCScanX ./output), some tools - # expect data files matching the new prefix. Create - # symlinks so the tool finds its inputs. - _symlink_prefix_data_files( - old, new, work_dir, set(source_hashes) - ) for relative in sorted(source_hashes): destination = work_dir / relative @@ -1860,6 +1884,7 @@ def render_runtime_package( src = raw_data_root / relative dst = work_dir / relative if src.is_file() and not dst.exists(): + dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) # When a prefix rename is active, create symlinks so tools that diff --git a/gfa_core/extension_stage_lightweight/service.py b/gfa_core/extension_stage_lightweight/service.py index 903a4eef4..90ad9523c 100644 --- a/gfa_core/extension_stage_lightweight/service.py +++ b/gfa_core/extension_stage_lightweight/service.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import tempfile from typing import Any, Callable, Iterator from uuid import uuid4 @@ -41,6 +42,34 @@ def _copy_tree(source: Path, destination: Path) -> None: shutil.copytree(source, destination, symlinks=True) +def _conda_activated_env(runtime_env: str) -> dict[str, str]: + """Return the full environment after ``conda run -n `` activation. + + ``conda run`` runs the env's activate.d/*.sh scripts (which set + PYTHONPATH/LD_LIBRARY_PATH and may prepend PATH) in addition to the + normal PATH/CONDA_* bookkeeping — more complete than just pointing + PATH at env/bin. + """ + try: + proc = subprocess.run( + ["conda", "run", "-n", runtime_env, "python", "-c", + "import os,json;print(json.dumps(dict(os.environ)))"], + capture_output=True, text=True, timeout=20, + ) + except Exception: + return {} + if proc.returncode != 0: + return {} + try: + data = json.loads(proc.stdout.strip()) + except ValueError: + return {} + if not isinstance(data, dict): + return {} + data.pop("_", None) + return data + + class LightweightExtensionService: """A small state machine that delegates all conversion/runtime facts to Phase A.""" @@ -119,6 +148,9 @@ class LightweightExtensionService: env["PATH"] = f"{conda_bin}:{existing_path}" if existing_path else conda_bin env["CONDA_PREFIX"] = conda_prefix env["CONDA_DEFAULT_ENV"] = runtime_env + # 用 conda run 的官方激活结果覆盖:activate.d 设置的 PYTHONPATH/LD_LIBRARY_PATH + # 以及 PATH 前置(如 MCScanX)都会被正确带进来;失败时保留上面的手动设置作为兜底。 + env.update(_conda_activated_env(runtime_env)) return env def _deepseek_candidate(self, facts: dict[str, Any], raw_code: Path) -> tuple[dict[str, Any], dict[str, Any]]: