fix(lightweight-extension): 输出前缀绑定、拖拽导入与前端入口修复
- runtime_binding: 验证时排除输入 sidecar 输出,目录参数跟随输出前缀重命名 - import-folder: 改 multipart 二进制上传,规避大文件 base64 报 Invalid string length - main: 前端入口切回 index.html,conda 路径兜底 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -135,33 +135,18 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
async function encodeBase64(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function importFolderToJob(hooks, rootName, files) {
|
||||
const payload = {
|
||||
job_id: String(hooks.jobInfo.job_id || '').trim(),
|
||||
root_name: rootName,
|
||||
files: [],
|
||||
relative_paths: [],
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('job_id', String(hooks.jobInfo.job_id || '').trim());
|
||||
form.append('root_name', rootName);
|
||||
for (const file of files) {
|
||||
payload.files.push(await encodeBase64(file));
|
||||
payload.relative_paths.push(normalizedRelativePath(file));
|
||||
form.append('files', file, normalizedRelativePath(file));
|
||||
form.append('relative_paths', normalizedRelativePath(file));
|
||||
}
|
||||
const response = await hooks.uploadExtensionWithProgress(
|
||||
'/api/job/import-folder',
|
||||
JSON.stringify(payload),
|
||||
form,
|
||||
'正在镜像导入 Job',
|
||||
{ 'Content-Type': 'application/json' },
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
|
||||
+22
-24
@@ -6,7 +6,6 @@ BotanicalBaseAgent - FastAPI Backend
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import csv
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -8830,7 +8829,7 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict):
|
||||
async def root():
|
||||
"""返回Vue3前端页面"""
|
||||
return FileResponse(
|
||||
Path(__file__).parent / "index_ml.html",
|
||||
Path(__file__).parent / "index.html",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
@@ -9059,34 +9058,29 @@ async def upload_file(files: List[UploadFile] = File(...), job_id: str = Form(""
|
||||
}
|
||||
|
||||
|
||||
class JobImportFolderRequest(BaseModel):
|
||||
job_id: str
|
||||
root_name: str = ""
|
||||
files: List[str] = Field(default_factory=list)
|
||||
relative_paths: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@app.post("/api/job/import-folder")
|
||||
async def import_job_folder(req: JobImportFolderRequest):
|
||||
job_id = str(req.job_id or "").strip()
|
||||
async def import_job_folder(
|
||||
job_id: str = Form(...),
|
||||
root_name: str = Form(""),
|
||||
files: List[UploadFile] = File(...),
|
||||
relative_paths: List[str] = Form(...),
|
||||
):
|
||||
job_id = str(job_id or "").strip()
|
||||
if not job_id:
|
||||
raise HTTPException(status_code=400, detail="job_id is required")
|
||||
if len(req.files) != len(req.relative_paths):
|
||||
if len(files) != len(relative_paths):
|
||||
raise HTTPException(status_code=400, detail="files and relative_paths length mismatch")
|
||||
|
||||
root_name = _safe_upload_name(req.root_name or "lightweight_extension_drop")
|
||||
root_name = _safe_upload_name(root_name or "lightweight_extension_drop")
|
||||
import_root = INPUT_ROOT / job_id / root_name
|
||||
import_root.mkdir(parents=True, exist_ok=True)
|
||||
saved_files: List[str] = []
|
||||
|
||||
for encoded, raw_relative in zip(req.files, req.relative_paths):
|
||||
for upload, raw_relative in zip(files, relative_paths):
|
||||
relative = Path(str(raw_relative or "").strip())
|
||||
if not raw_relative or relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise HTTPException(status_code=400, detail=f"invalid relative path: {raw_relative}")
|
||||
try:
|
||||
content = base64.b64decode(str(encoded or "").encode("utf-8"), validate=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid base64 payload for {raw_relative}: {exc}") from exc
|
||||
content = await upload.read()
|
||||
destination = import_root / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content)
|
||||
@@ -9185,6 +9179,10 @@ class ExtensionStageEnvResolveRequest(BaseModel):
|
||||
env_declaration: Optional[str] = None
|
||||
|
||||
|
||||
def _conda_exe() -> str:
|
||||
"""Resolve the conda executable even when it is not on the server process's PATH."""
|
||||
return shutil.which("conda") or "/home/xzq/miniconda3/bin/conda"
|
||||
|
||||
|
||||
@app.get("/api/extension-stages/envs")
|
||||
async def list_extension_stage_envs():
|
||||
@@ -9192,7 +9190,7 @@ async def list_extension_stage_envs():
|
||||
import json as _json
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "env", "list", "--json",
|
||||
_conda_exe(), "env", "list", "--json",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -9200,7 +9198,7 @@ async def list_extension_stage_envs():
|
||||
if proc.returncode != 0:
|
||||
# fallback: try parsing plain-text output
|
||||
proc2 = await asyncio.create_subprocess_exec(
|
||||
"conda", "env", "list",
|
||||
_conda_exe(), "env", "list",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -9249,7 +9247,7 @@ async def _available_extension_env_names() -> List[str]:
|
||||
|
||||
async def _python_packages_for_env(env_name: str) -> set[str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env_name, "python", "-c",
|
||||
_conda_exe(), "run", "-n", env_name, "python", "-c",
|
||||
"import json, importlib.metadata as m; print(json.dumps(sorted({dist.metadata.get('Name', '') for dist in m.distributions() if dist.metadata.get('Name', '')})))",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -9266,7 +9264,7 @@ async def _python_packages_for_env(env_name: str) -> set[str]:
|
||||
|
||||
async def _r_packages_for_env(env_name: str) -> set[str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env_name, "Rscript", "-e",
|
||||
_conda_exe(), "run", "-n", env_name, "Rscript", "-e",
|
||||
"cat(paste(sort(rownames(installed.packages())), collapse='\n'))",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -9283,7 +9281,7 @@ async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest):
|
||||
if req.env_declaration:
|
||||
env = req.env_declaration.strip()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env, "python", "--version",
|
||||
_conda_exe(), "run", "-n", env, "python", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -9309,7 +9307,7 @@ async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest):
|
||||
|
||||
for dep in list(dict.fromkeys(d.lower() for d in python_imports + r_packages)):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", dep, "python", "--version",
|
||||
_conda_exe(), "run", "-n", dep, "python", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -1004,6 +1004,33 @@ def resolve_runtime_bindings(
|
||||
binding["original_path"]: binding["runtime_path"]
|
||||
for binding in input_bindings or []
|
||||
}
|
||||
direct_output_fact_ids = {
|
||||
fact_id
|
||||
for target in contract.get("patch_targets", [])
|
||||
if isinstance(target, dict)
|
||||
and target.get("patch_kind") in {"output_path", "output_reference_path"}
|
||||
for fact_id in target.get("fact_ids", [])
|
||||
if isinstance(fact_id, str)
|
||||
}
|
||||
# Outputs owned by an input (the input itself or a sidecar like
|
||||
# ``Pfam-A.WRKY-subset.hmm.h3f`` next to ``Pfam-A.WRKY-subset.hmm``) do not
|
||||
# belong to the output-prefix namespace; they are renamed via their input
|
||||
# relationship. Excluding them keeps the shared-prefix derivation from
|
||||
# being dragged to ``.`` by a top-level sidecar while the remaining outputs
|
||||
# live under a common directory.
|
||||
input_owned_paths = {
|
||||
output["path"]
|
||||
for output in output_files
|
||||
if _runtime_input_owned_output(
|
||||
output["path"],
|
||||
runtime_by_original,
|
||||
allow_sidecar=output["fact_id"] not in direct_output_fact_ids,
|
||||
)
|
||||
is not None
|
||||
}
|
||||
prefix_output_files = [
|
||||
output for output in output_files if output["path"] not in input_owned_paths
|
||||
]
|
||||
participant_options: list[set[tuple[str, str]]] = []
|
||||
participating_paths: set[str] = set()
|
||||
for output in output_files:
|
||||
@@ -1055,11 +1082,11 @@ def resolve_runtime_bindings(
|
||||
*[{old for old, _new in options} for options in participant_options]
|
||||
)
|
||||
if not common_old:
|
||||
old_prefix = _shared_output_prefix(output_files)
|
||||
old_prefix = _shared_output_prefix(prefix_output_files)
|
||||
else:
|
||||
old_prefix = max(common_old, key=lambda value: (len(value), value))
|
||||
else:
|
||||
old_prefix = _shared_output_prefix(output_files)
|
||||
old_prefix = _shared_output_prefix(prefix_output_files)
|
||||
if old_prefix is None:
|
||||
return _blocked_result(
|
||||
contract,
|
||||
@@ -1069,14 +1096,6 @@ def resolve_runtime_bindings(
|
||||
parameter_bindings=parameter_bindings,
|
||||
)
|
||||
new_prefix = explicit_prefix or "output"
|
||||
direct_output_fact_ids = {
|
||||
fact_id
|
||||
for target in contract.get("patch_targets", [])
|
||||
if isinstance(target, dict)
|
||||
and target.get("patch_kind") in {"output_path", "output_reference_path"}
|
||||
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", [])
|
||||
@@ -1412,6 +1431,17 @@ def _binding_replacements(
|
||||
raise RuntimeBindingError(
|
||||
"PARAMETER_VALUE_INVALID", f"unsafe runtime parameter: {fact_id}"
|
||||
)
|
||||
# 输出目录参数(--outdir / -oc / mkdir -p 的值)跟随输出前缀重命名,
|
||||
# 否则渲染后的 run.sh 里目录参数与 file 输出路径指向不一致的目录。
|
||||
if isinstance(prefix, dict):
|
||||
old_prefix = prefix.get("old_prefix")
|
||||
new_prefix = prefix.get("new_prefix")
|
||||
if (
|
||||
isinstance(old_prefix, str)
|
||||
and isinstance(new_prefix, str)
|
||||
and _output_has_prefix(runtime, old_prefix)
|
||||
):
|
||||
runtime = _replace_output_prefix(runtime, old_prefix, new_prefix)
|
||||
add(f"parameter:{fact_id}", original, runtime)
|
||||
|
||||
targets_by_key = {
|
||||
|
||||
Reference in New Issue
Block a user