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]]: