fix(lightweight-extension): 环境激活、前缀重命名、数据复制与上传进度
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <env>`` 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]]:
|
||||
|
||||
Reference in New Issue
Block a user