diff --git a/apps/web/vue3/lightWeightExtensionTest.js b/apps/web/vue3/lightWeightExtensionTest.js index 5732266a3..178458777 100644 --- a/apps/web/vue3/lightWeightExtensionTest.js +++ b/apps/web/vue3/lightWeightExtensionTest.js @@ -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) { diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 74bdc9a5f..c42b46108 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -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, ) diff --git a/gfa_core/extension_stage_lightweight/runtime_binding.py b/gfa_core/extension_stage_lightweight/runtime_binding.py index 595996f95..da71c4cbb 100644 --- a/gfa_core/extension_stage_lightweight/runtime_binding.py +++ b/gfa_core/extension_stage_lightweight/runtime_binding.py @@ -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 = {