From aff4ecb9f2b0e89a3ba3daa1d7e820b0cb196d6d Mon Sep 17 00:00:00 2001 From: Garming Date: Tue, 11 Aug 2026 20:58:18 +0800 Subject: [PATCH 1/2] feat(studio): add sandbox-backed project migration --- frontend/README.md | 22 +- frontend/server/deployment_source.py | 227 ++ frontend/server/migration/__init__.py | 15 + frontend/server/migration/contracts.py | 741 ++++++ frontend/server/migration/gateway.py | 811 +++++++ frontend/server/migration/models.py | 130 ++ frontend/server/migration/routes.py | 276 +++ frontend/server/migration/service.py | 2018 +++++++++++++++++ frontend/src/App.tsx | 26 +- frontend/src/adk/client.ts | 9 +- frontend/src/adk/migrations.ts | 799 +++++++ frontend/src/adk/telemetryEvents.ts | 2 + frontend/src/create/CodePackageCreate.tsx | 59 +- frontend/src/migrations/MigrationIcons.tsx | 88 + .../src/migrations/MigrationWorkspace.css | 1118 +++++++++ .../src/migrations/MigrationWorkspace.tsx | 1503 ++++++++++++ frontend/tests/codePackageDeploy.test.mjs | 23 +- frontend/tests/migrationClient.test.mjs | 78 + frontend/tests/migrationWorkspace.test.mjs | 129 ++ tests/cli/test_studio_rbac.py | 201 ++ tests/frontend/test_deployment_source.py | 189 ++ tests/frontend/test_migration_server.py | 2006 ++++++++++++++++ veadk/cli/cli_frontend.py | 86 +- ...2H.js => MarkdownPromptEditor-D7z35Hip.js} | 2 +- veadk/webui/assets/index-Bg99KZ8e.js | 1139 ---------- ...{index-DCJLnNdm.css => index-DLWQtyNS.css} | 2 +- veadk/webui/assets/index-DQ24kt3h.js | 1140 ++++++++++ veadk/webui/index.html | 4 +- 28 files changed, 11661 insertions(+), 1182 deletions(-) create mode 100644 frontend/server/deployment_source.py create mode 100644 frontend/server/migration/__init__.py create mode 100644 frontend/server/migration/contracts.py create mode 100644 frontend/server/migration/gateway.py create mode 100644 frontend/server/migration/models.py create mode 100644 frontend/server/migration/routes.py create mode 100644 frontend/server/migration/service.py create mode 100644 frontend/src/adk/migrations.ts create mode 100644 frontend/src/migrations/MigrationIcons.tsx create mode 100644 frontend/src/migrations/MigrationWorkspace.css create mode 100644 frontend/src/migrations/MigrationWorkspace.tsx create mode 100644 frontend/tests/migrationClient.test.mjs create mode 100644 frontend/tests/migrationWorkspace.test.mjs create mode 100644 tests/frontend/test_deployment_source.py create mode 100644 tests/frontend/test_migration_server.py rename veadk/webui/assets/{MarkdownPromptEditor-BZZjAY2H.js => MarkdownPromptEditor-D7z35Hip.js} (99%) delete mode 100644 veadk/webui/assets/index-Bg99KZ8e.js rename veadk/webui/assets/{index-DCJLnNdm.css => index-DLWQtyNS.css} (87%) create mode 100644 veadk/webui/assets/index-DQ24kt3h.js diff --git a/frontend/README.md b/frontend/README.md index 2673d87a..8b941257 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -138,9 +138,25 @@ server that `veadk frontend` launches — no separate backend. - **Code-package deployment**: upload a ZIP project from the add-Agent menu, inspect or edit its files in the existing code browser, then choose the region and public/VPC network before deploying it to AgentKit. The package - must contain a root `app.py`; Studio removes a single wrapping directory, - rejects unsafe paths, and shows upload, image build, Runtime creation, and - service publishing as separate deployment stages. + uses `agentkit.yaml` `common.entry_point` when declared and otherwise keeps + root `app.py` as the compatible default. Studio removes a single wrapping + directory, rejects unsafe paths, and shows upload, image build, Runtime + creation, and service publishing as separate deployment stages. +- **Existing-project migration**: upload one local ZIP of at most 50 MiB from + the add-Agent menu. Studio creates one user-owned Dev Sandbox Session with a + one-hour TTL, then asks the preinstalled Codex to perform read-only framework, + entry-point, and migration-boundary analysis. Migration starts only after the + user confirms the framework, entry point, and open questions. Structured + frameworks run the preinstalled `ak migrate`; Dify and Any projects run + `ak migrate --execution in-place` with Codex in the same Session. State, + logs, and artifacts remain only under + `/home/gem/.studio/migration/v1/` in that Session. Preview, download, and + Runtime deployment stop when the Session expires. Runtime deployment resolves + and verifies the owned Session artifact on the server instead of trusting + browser-provided files or entry points. AgentKit CLI `0.51.1` is only the + current baseline; these CLI changes must be released as a new version. The + Dev Sandbox image must pin that migration-capable release and its SHA256 at + image build time. - **Built-in code execution**: selecting `代码执行` adds VeADK's `run_code` tool to generated Python and reveals the required `AGENTKIT_TOOL_ID` sandbox field and optional `AGENTKIT_TOOL_REGION` field below the built-in tool list. diff --git a/frontend/server/deployment_source.py b/frontend/server/deployment_source.py new file mode 100644 index 00000000..c905b01e --- /dev/null +++ b/frontend/server/deployment_source.py @@ -0,0 +1,227 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validate and materialize AgentKit deployment source packages.""" + +from __future__ import annotations + +import hashlib +import io +import re +import stat +import zipfile +from collections.abc import Mapping +from pathlib import Path, PurePosixPath + +import yaml + +_DEFAULT_ENTRY_POINT = "app.py" +_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +_MAX_ARCHIVE_FILES = 20_000 +_MAX_EXPANDED_BYTES = 512 * 1024 * 1024 +_MAX_FILE_BYTES = 128 * 1024 * 1024 +_MAX_PATH_BYTES = 4 * 1024 +_MAX_PATH_DEPTH = 64 +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class DeploymentSourceError(ValueError): + """Deployment source does not satisfy the trusted package contract.""" + + +def _relative_path(value: object, *, field: str) -> str: + if not isinstance(value, str): + raise DeploymentSourceError(f"{field} 必须是相对文件路径。") + path = PurePosixPath(value) + normalized = path.as_posix() + if ( + not value + or value.endswith("/") + or "\\" in value + or any(ord(character) < 32 or ord(character) == 127 for character in value) + or value != normalized + or path.is_absolute() + or ".." in path.parts + or "." in path.parts + or len(path.parts) > _MAX_PATH_DEPTH + or len(normalized.encode("utf-8")) > _MAX_PATH_BYTES + ): + raise DeploymentSourceError(f"{field} 不是安全的相对文件路径:{value}") + return normalized + + +def _target(base: Path, relative: str) -> Path: + target = (base / relative).resolve() + if not target.is_relative_to(base.resolve()): + raise DeploymentSourceError(f"部署文件路径越界:{relative}") + return target + + +def _configured_entry_point(base: Path) -> str: + manifest_path = base / "agentkit.yaml" + if not manifest_path.is_file(): + return _DEFAULT_ENTRY_POINT + try: + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError) as error: + raise DeploymentSourceError(f"agentkit.yaml 无法解析:{error}") from error + if manifest is None: + return _DEFAULT_ENTRY_POINT + if not isinstance(manifest, Mapping): + raise DeploymentSourceError("agentkit.yaml 根节点必须是对象。") + common = manifest.get("common") + if common is None: + return _DEFAULT_ENTRY_POINT + if not isinstance(common, Mapping): + raise DeploymentSourceError("agentkit.yaml 的 common 必须是对象。") + value = common.get("entry_point") + if value is None: + return _DEFAULT_ENTRY_POINT + return _relative_path(value, field="agentkit.yaml common.entry_point") + + +def _require_entry_point(base: Path, entry_point: str) -> str: + target = _target(base, entry_point) + if not target.is_file() or target.is_symlink(): + raise DeploymentSourceError(f"部署入口文件不存在:{entry_point}") + return entry_point + + +def _reject_path_collisions(paths: set[str]) -> None: + for relative in paths: + path = PurePosixPath(relative) + for parent in path.parents: + if parent == PurePosixPath("."): + break + if parent.as_posix() in paths: + raise DeploymentSourceError( + f"部署文件存在文件与目录路径冲突:{parent.as_posix()}" + ) + + +def write_inline_source(base: Path, files: object) -> str: + """Write browser-provided text files and resolve a compatible entry point.""" + if not isinstance(files, list) or not files: + raise DeploymentSourceError("No files provided") + seen: set[str] = set() + validated: list[tuple[str, str]] = [] + for item in files: + if not isinstance(item, Mapping): + raise DeploymentSourceError("部署文件格式无效。") + relative = _relative_path(item.get("path"), field="部署文件路径") + if relative in seen: + raise DeploymentSourceError(f"部署文件重复:{relative}") + seen.add(relative) + content = item.get("content") + if not isinstance(content, str): + raise DeploymentSourceError(f"部署文件内容必须是文本:{relative}") + validated.append((relative, content)) + _reject_path_collisions(seen) + for relative, content in validated: + target = _target(base, relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return _require_entry_point(base, _configured_entry_point(base)) + + +def _manifest_files(manifest: object) -> tuple[dict[str, tuple[int, str]], str]: + if not isinstance(manifest, Mapping): + raise DeploymentSourceError("迁移产物清单格式无效。") + files = manifest.get("files") + startup = manifest.get("startup") + if ( + not isinstance(files, list) + or not files + or len(files) > _MAX_ARCHIVE_FILES + or not isinstance(startup, Mapping) + ): + raise DeploymentSourceError("迁移产物文件清单格式无效。") + descriptors: dict[str, tuple[int, str]] = {} + expanded_bytes = 0 + for item in files: + if not isinstance(item, Mapping): + raise DeploymentSourceError("迁移产物文件清单格式无效。") + relative = _relative_path(item.get("path"), field="迁移产物路径") + size = item.get("size") + digest = item.get("sha256") + if ( + relative in descriptors + or isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + or size > _MAX_FILE_BYTES + or not isinstance(digest, str) + or not _SHA256_RE.fullmatch(digest) + ): + raise DeploymentSourceError("迁移产物文件清单格式无效。") + expanded_bytes += size + if expanded_bytes > _MAX_EXPANDED_BYTES: + raise DeploymentSourceError("迁移产物解压后超过 512 MiB。") + descriptors[relative] = (size, digest) + _reject_path_collisions(set(descriptors)) + entry_point = _relative_path( + startup.get("module"), + field="迁移产物 startup.module", + ) + if entry_point not in descriptors: + raise DeploymentSourceError("迁移产物启动文件不在文件清单中。") + return descriptors, entry_point + + +def extract_migration_source( + base: Path, + archive_content: bytes, + manifest: object, +) -> str: + """Verify every migration ZIP entry against its manifest before writing.""" + if not archive_content or len(archive_content) > _MAX_ARCHIVE_BYTES: + raise DeploymentSourceError("迁移产物 ZIP 大小无效。") + descriptors, entry_point = _manifest_files(manifest) + seen: set[str] = set() + try: + with zipfile.ZipFile(io.BytesIO(archive_content)) as archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) != len(descriptors): + raise DeploymentSourceError("迁移产物 ZIP 与文件清单不一致。") + for info in infos: + relative = _relative_path(info.filename, field="迁移产物 ZIP 路径") + mode = info.external_attr >> 16 + if ( + relative in seen + or info.flag_bits & 0x1 + or stat.S_IFMT(mode) == stat.S_IFLNK + ): + raise DeploymentSourceError("迁移产物 ZIP 包含不安全文件。") + seen.add(relative) + descriptor = descriptors.get(relative) + if descriptor is None or info.file_size != descriptor[0]: + raise DeploymentSourceError("迁移产物 ZIP 与文件清单不一致。") + content = archive.read(info) + if hashlib.sha256(content).hexdigest() != descriptor[1]: + raise DeploymentSourceError("迁移产物文件完整性校验失败。") + target = _target(base, relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + except zipfile.BadZipFile as error: + raise DeploymentSourceError("迁移产物 ZIP 格式无效。") from error + if seen != set(descriptors): + raise DeploymentSourceError("迁移产物 ZIP 与文件清单不一致。") + return _require_entry_point(base, entry_point) + + +__all__ = [ + "DeploymentSourceError", + "extract_migration_source", + "write_inline_source", +] diff --git a/frontend/server/migration/__init__.py b/frontend/server/migration/__init__.py new file mode 100644 index 00000000..93f620af --- /dev/null +++ b/frontend/server/migration/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Studio migration orchestration backed by one Dev Sandbox Session per task.""" diff --git a/frontend/server/migration/contracts.py b/frontend/server/migration/contracts.py new file mode 100644 index 00000000..f54ea49b --- /dev/null +++ b/frontend/server/migration/contracts.py @@ -0,0 +1,741 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Strict contracts for state files produced inside a Migration Session.""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import PurePosixPath + +from .models import ( + MIGRATION_FRAMEWORKS, + STRUCTURED_MIGRATION_FRAMEWORKS, + is_valid_structured_entry, +) + +_MAX_PATH_BYTES = 4 * 1024 +_MAX_PATH_DEPTH = 64 +_MAX_TEXT_LENGTH = 20_000 +_MAX_DELIVERY_FILES = 20_000 +_MAX_DELIVERY_BYTES = 512 * 1024 * 1024 +_MAX_DELIVERY_FILE_BYTES = 128 * 1024 * 1024 +_MAX_ARTIFACT_BYTES = 512 * 1024 * 1024 +_MAX_SOURCE_BYTES = 50 * 1024 * 1024 +_MAX_SOURCE_EXPANDED_BYTES = 1024 * 1024 * 1024 +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_FILE_MODE_RE = re.compile(r"^0[0-7]{1,3}$") +_ENVIRONMENT_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_TASK_ID_RE = re.compile(r"^migration-v1-[0-9a-f]{32}$") +_SOURCE_FILE_NAME_RE = re.compile( + r"^[^/\\\x00-\x1f\x7f]{1,255}\.zip$", + re.IGNORECASE, +) +_APP_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") +_PYTHON_OBJECT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$") +_ACTIVE_DELIVERY_STATES = {"migrating", "validating", "packaging"} +_TERMINAL_DELIVERY_STATES = { + "succeeded", + "succeeded_with_warnings", + "partial", +} + + +class MigrationContractError(ValueError): + """A remote state file does not match its versioned contract.""" + + +def _exact_keys( + value: dict[str, object], + *, + required: set[str], + optional: set[str] | frozenset[str] = frozenset(), +) -> None: + keys = set(value) + if not required.issubset(keys) or not keys.issubset(required | optional): + raise MigrationContractError("unexpected object fields") + + +def _text( + value: object, + *, + allow_empty: bool = True, + maximum: int = _MAX_TEXT_LENGTH, +) -> str: + if not isinstance(value, str) or len(value) > maximum: + raise MigrationContractError("invalid text") + if not allow_empty and not value.strip(): + raise MigrationContractError("empty text") + return value + + +def _string_list( + value: object, + *, + maximum_items: int, + allow_empty_items: bool = False, +) -> list[str]: + if not isinstance(value, list) or len(value) > maximum_items: + raise MigrationContractError("invalid string list") + return [_text(item, allow_empty=allow_empty_items, maximum=4_000) for item in value] + + +def _relative_path(value: object) -> str: + text = _text(value, allow_empty=False, maximum=_MAX_PATH_BYTES) + path = PurePosixPath(text) + if ( + not path.parts + or text == "." + or text != path.as_posix() + or path.is_absolute() + or "." in path.parts + or ".." in path.parts + or "\\" in text + or len(path.parts) > _MAX_PATH_DEPTH + or any(ord(character) < 32 or ord(character) == 127 for character in text) + or len(text.encode("utf-8")) > _MAX_PATH_BYTES + ): + raise MigrationContractError("unsafe relative path") + return text + + +def _sha256(value: object) -> str: + if not isinstance(value, str) or not _SHA256_RE.fullmatch(value): + raise MigrationContractError("invalid sha256") + return value + + +def _bounded_integer( + value: object, + *, + minimum: int = 0, + maximum: int, +) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < minimum + or value > maximum + ): + raise MigrationContractError("invalid integer") + return value + + +def _timestamp_text(value: object) -> str: + text = _text(value, allow_empty=False, maximum=64) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as error: + raise MigrationContractError("invalid timestamp") from error + if parsed.tzinfo is None: + raise MigrationContractError("timestamp is missing a timezone") + return text + + +def _reject_path_collisions(paths: set[str]) -> None: + for value in paths: + path = PurePosixPath(value) + for parent in path.parents: + if parent == PurePosixPath("."): + break + if parent.as_posix().casefold() in paths: + raise MigrationContractError("file and directory paths collide") + + +def _framework(value: object) -> str: + if value not in MIGRATION_FRAMEWORKS: + raise MigrationContractError("unsupported framework") + return str(value) + + +def validate_migration_request( + value: object, + *, + expected_task_id: str, + expected_ttl_seconds: int, +) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("migration request must be an object") + _exact_keys( + value, + required={ + "schema_version", + "task_id", + "source_file_name", + "instruction", + "session_ttl_seconds", + "created_at", + }, + ) + if ( + value.get("schema_version") != 1 + or value.get("task_id") != expected_task_id + or not _TASK_ID_RE.fullmatch(expected_task_id) + or value.get("session_ttl_seconds") != expected_ttl_seconds + ): + raise MigrationContractError("invalid migration request identity") + source_file_name = value.get("source_file_name") + if not isinstance(source_file_name, str) or not _SOURCE_FILE_NAME_RE.fullmatch( + source_file_name + ): + raise MigrationContractError("invalid source file name") + _text(value.get("instruction"), maximum=_MAX_TEXT_LENGTH) + created_at = value.get("created_at") + if isinstance(created_at, str): + _timestamp_text(created_at) + else: + _bounded_integer(created_at, maximum=10**12) + return {str(key): item for key, item in value.items()} + + +def validate_source_status(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("source status must be an object") + _exact_keys( + value, + required={ + "schema_version", + "sha256", + "size", + "file_count", + "expanded_bytes", + }, + ) + if value.get("schema_version") != 1: + raise MigrationContractError("unsupported source status schema") + _sha256(value.get("sha256")) + _bounded_integer( + value.get("size"), + minimum=1, + maximum=_MAX_SOURCE_BYTES, + ) + _bounded_integer( + value.get("file_count"), + minimum=1, + maximum=_MAX_DELIVERY_FILES, + ) + _bounded_integer( + value.get("expanded_bytes"), + maximum=_MAX_SOURCE_EXPANDED_BYTES, + ) + return {str(key): item for key, item in value.items()} + + +def validate_analysis_status(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("analysis status must be an object") + _exact_keys( + value, + required={"schema_version", "state", "message"}, + optional={"error"}, + ) + state = value.get("state") + if value.get("schema_version") != 1 or state not in { + "analyzing", + "ready", + "failed", + }: + raise MigrationContractError("invalid analysis status") + _text(value.get("message"), allow_empty=False, maximum=4_000) + if state == "failed": + if "error" not in value: + raise MigrationContractError("failed analysis is missing an error") + _error(value["error"]) + elif "error" in value: + raise MigrationContractError("non-failed analysis exposed an error") + return {str(key): item for key, item in value.items()} + + +def validate_confirmation( + value: object, + *, + expected_task_id: str, +) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("confirmation must be an object") + _exact_keys( + value, + required={ + "schema_version", + "task_id", + "source_archive_sha256", + "framework", + "entry", + "app_name", + "instruction", + "answers", + "confirmed_at", + }, + ) + if ( + value.get("schema_version") != 1 + or value.get("task_id") != expected_task_id + or not _TASK_ID_RE.fullmatch(expected_task_id) + ): + raise MigrationContractError("invalid confirmation identity") + _sha256(value.get("source_archive_sha256")) + framework = _framework(value.get("framework")) + entry = value.get("entry") + if framework in STRUCTURED_MIGRATION_FRAMEWORKS: + if not is_valid_structured_entry(entry): + raise MigrationContractError("invalid structured confirmation") + elif entry is not None: + raise MigrationContractError("agentic confirmation has an entry") + app_name = value.get("app_name") + if not isinstance(app_name, str) or not _APP_NAME_RE.fullmatch(app_name): + raise MigrationContractError("invalid app name") + _text(value.get("instruction"), maximum=_MAX_TEXT_LENGTH) + answers = value.get("answers") + if not isinstance(answers, dict) or len(answers) > 50: + raise MigrationContractError("invalid confirmation answers") + for key, answer in answers.items(): + _text(key, allow_empty=False, maximum=128) + _text(answer, maximum=4_000) + _bounded_integer(value.get("confirmed_at"), maximum=10**12) + return {str(key): item for key, item in value.items()} + + +def validate_process_exit(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("process exit must be an object") + _exact_keys(value, required={"schema_version", "exit_code"}) + if value.get("schema_version") != 1: + raise MigrationContractError("unsupported process exit schema") + _bounded_integer(value.get("exit_code"), maximum=255) + return {str(key): item for key, item in value.items()} + + +def validate_stopped_status(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("stopped status must be an object") + _exact_keys(value, required={"schema_version", "state", "message"}) + if value.get("schema_version") != 1 or value.get("state") != "cancelled": + raise MigrationContractError("invalid stopped status") + _text(value.get("message"), allow_empty=False, maximum=4_000) + return {str(key): item for key, item in value.items()} + + +def validate_analysis_result(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("analysis must be an object") + _exact_keys( + value, + required={ + "schema_version", + "summary", + "frameworks", + "recommended", + "entries", + "boundary", + "questions", + "warnings", + }, + ) + if value.get("schema_version") != 1: + raise MigrationContractError("unsupported analysis schema") + _text(value.get("summary")) + + frameworks = value.get("frameworks") + if not isinstance(frameworks, list) or len(frameworks) > 20: + raise MigrationContractError("invalid framework candidates") + seen_frameworks: set[str] = set() + for item in frameworks: + if not isinstance(item, dict): + raise MigrationContractError("invalid framework candidate") + _exact_keys(item, required={"id", "confidence", "evidence"}) + framework = _framework(item.get("id")) + if framework in seen_frameworks or item.get("confidence") not in { + "high", + "medium", + "low", + }: + raise MigrationContractError("invalid framework candidate") + seen_frameworks.add(framework) + evidence = item.get("evidence") + if not isinstance(evidence, list) or len(evidence) > 100: + raise MigrationContractError("invalid framework evidence") + for evidence_item in evidence: + if not isinstance(evidence_item, dict): + raise MigrationContractError("invalid framework evidence") + _exact_keys(evidence_item, required={"path", "line", "reason"}) + _relative_path(evidence_item.get("path")) + line = evidence_item.get("line") + if isinstance(line, bool) or not isinstance(line, int) or line < 1: + raise MigrationContractError("invalid evidence line") + _text(evidence_item.get("reason"), allow_empty=False, maximum=4_000) + + recommended = value.get("recommended") + if not isinstance(recommended, dict): + raise MigrationContractError("invalid recommendation") + _exact_keys(recommended, required={"framework", "entry", "reason"}) + recommended_framework = _framework(recommended.get("framework")) + recommended_entry = recommended.get("entry") + if recommended_entry is not None: + if ( + recommended_framework not in STRUCTURED_MIGRATION_FRAMEWORKS + or not is_valid_structured_entry(recommended_entry) + ): + raise MigrationContractError("invalid recommended entry") + _text(recommended.get("reason"), maximum=4_000) + + entries = value.get("entries") + if not isinstance(entries, list) or len(entries) > 100: + raise MigrationContractError("invalid entry candidates") + seen_entries: set[tuple[str, str]] = set() + for item in entries: + if not isinstance(item, dict): + raise MigrationContractError("invalid entry candidate") + _exact_keys(item, required={"value", "framework", "evidence"}) + framework = _framework(item.get("framework")) + entry = item.get("value") + if ( + framework not in STRUCTURED_MIGRATION_FRAMEWORKS + or not is_valid_structured_entry(entry) + or (framework, str(entry)) in seen_entries + ): + raise MigrationContractError("invalid entry candidate") + seen_entries.add((framework, str(entry))) + _text(item.get("evidence"), allow_empty=False, maximum=4_000) + + boundary = value.get("boundary") + if not isinstance(boundary, dict): + raise MigrationContractError("invalid migration boundary") + _exact_keys(boundary, required={"include", "exclude"}) + _string_list(boundary.get("include"), maximum_items=200) + _string_list(boundary.get("exclude"), maximum_items=200) + + questions = value.get("questions") + if not isinstance(questions, list) or len(questions) > 50: + raise MigrationContractError("invalid questions") + seen_question_ids: set[str] = set() + for item in questions: + if not isinstance(item, dict): + raise MigrationContractError("invalid question") + _exact_keys(item, required={"id", "prompt", "required"}) + question_id = _text( + item.get("id"), + allow_empty=False, + maximum=128, + ).strip() + if question_id in seen_question_ids or not isinstance( + item.get("required"), + bool, + ): + raise MigrationContractError("invalid question") + seen_question_ids.add(question_id) + _text(item.get("prompt"), allow_empty=False, maximum=4_000) + + _string_list(value.get("warnings"), maximum_items=100) + return {str(key): item for key, item in value.items()} + + +def _error(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("invalid delivery error") + _exact_keys(value, required={"code", "message", "retryable"}) + _text(value.get("code"), allow_empty=False, maximum=128) + _text(value.get("message"), allow_empty=False, maximum=4_000) + if value.get("retryable") is not False: + raise MigrationContractError("delivery errors are not retryable") + return value + + +def validate_delivery_status( + value: object, + *, + expected_run_id: str, +) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("delivery status must be an object") + _exact_keys( + value, + required={ + "schema_version", + "run_id", + "sequence", + "state", + "phase", + "message", + "artifact", + "updated_at", + }, + optional={"error"}, + ) + sequence = value.get("sequence") + state = value.get("state") + if ( + value.get("schema_version") != 1 + or value.get("run_id") != expected_run_id + or isinstance(sequence, bool) + or not isinstance(sequence, int) + or sequence < 1 + or state not in _ACTIVE_DELIVERY_STATES | _TERMINAL_DELIVERY_STATES | {"failed"} + ): + raise MigrationContractError("invalid delivery status") + _text(value.get("phase"), allow_empty=False, maximum=128) + _text(value.get("message"), allow_empty=False, maximum=4_000) + _timestamp_text(value.get("updated_at")) + + artifact = value.get("artifact") + if not isinstance(artifact, dict): + raise MigrationContractError("invalid artifact status") + _exact_keys( + artifact, + required={ + "state", + "preview_ready", + "download_ready", + "deploy_ready", + }, + ) + if artifact.get("state") not in {"none", "collecting", "ready", "unavailable"}: + raise MigrationContractError("invalid artifact state") + readiness = ( + artifact.get("preview_ready"), + artifact.get("download_ready"), + artifact.get("deploy_ready"), + ) + if any(not isinstance(item, bool) for item in readiness): + raise MigrationContractError("invalid artifact readiness") + if state in _ACTIVE_DELIVERY_STATES and ( + any(readiness) or artifact.get("state") not in {"none", "collecting"} + ): + raise MigrationContractError("active delivery exposed an artifact") + if state in _TERMINAL_DELIVERY_STATES and ( + artifact.get("state") != "ready" + or artifact.get("preview_ready") is not True + or artifact.get("download_ready") is not True + ): + raise MigrationContractError("terminal delivery artifact is incomplete") + if state == "failed" and ( + artifact.get("state") != "unavailable" or any(readiness) or "error" not in value + ): + raise MigrationContractError("failed delivery state is inconsistent") + if "error" in value: + _error(value["error"]) + return {str(key): item for key, item in value.items()} + + +def validate_delivery_result( + value: object, + *, + expected_run_id: str, + expected_status: str, +) -> dict[str, object]: + if not isinstance(value, dict): + raise MigrationContractError("delivery result must be an object") + _exact_keys( + value, + required={ + "schema_version", + "run_id", + "cli", + "migration", + "status", + "files", + "startup", + "environment", + "verification", + "warnings", + "report", + "artifact", + "created_at", + }, + ) + if ( + value.get("schema_version") != 1 + or value.get("run_id") != expected_run_id + or value.get("status") != expected_status + or expected_status not in _TERMINAL_DELIVERY_STATES + ): + raise MigrationContractError("delivery result identity does not match") + + cli = value.get("cli") + if not isinstance(cli, dict): + raise MigrationContractError("invalid cli descriptor") + _exact_keys(cli, required={"name", "version"}) + if cli.get("name") != "agentkit-cli": + raise MigrationContractError("invalid cli name") + _text(cli.get("version"), allow_empty=False, maximum=128) + + migration = value.get("migration") + if not isinstance(migration, dict): + raise MigrationContractError("invalid migration descriptor") + _exact_keys( + migration, + required={ + "engine", + "framework", + "source_sha256", + "provenance_sha256", + }, + optional={"entry"}, + ) + if migration.get("engine") not in {"structured", "agentic"}: + raise MigrationContractError("invalid migration engine") + framework = _framework(migration.get("framework")) + _sha256(migration.get("source_sha256")) + _sha256(migration.get("provenance_sha256")) + entry = migration.get("entry") + if migration["engine"] == "structured": + if ( + framework not in STRUCTURED_MIGRATION_FRAMEWORKS + or not is_valid_structured_entry(entry) + ): + raise MigrationContractError("invalid structured migration") + elif framework in STRUCTURED_MIGRATION_FRAMEWORKS or entry is not None: + raise MigrationContractError("invalid agentic migration") + + files = value.get("files") + if not isinstance(files, list) or not files or len(files) > _MAX_DELIVERY_FILES: + raise MigrationContractError("invalid delivery files") + file_paths: set[str] = set() + total_bytes = 0 + for item in files: + if not isinstance(item, dict): + raise MigrationContractError("invalid delivery file") + _exact_keys(item, required={"path", "size", "sha256", "mode"}) + path = _relative_path(item.get("path")) + folded_path = path.casefold() + if folded_path in file_paths: + raise MigrationContractError("duplicate delivery file") + file_paths.add(folded_path) + total_bytes += _bounded_integer( + item.get("size"), + maximum=_MAX_DELIVERY_FILE_BYTES, + ) + if total_bytes > _MAX_DELIVERY_BYTES: + raise MigrationContractError("delivery files exceed size limit") + _sha256(item.get("sha256")) + mode = item.get("mode") + if not isinstance(mode, str) or not _FILE_MODE_RE.fullmatch(mode): + raise MigrationContractError("invalid delivery file mode") + _reject_path_collisions(file_paths) + + startup = value.get("startup") + if not isinstance(startup, dict): + raise MigrationContractError("invalid startup descriptor") + _exact_keys( + startup, + required={"module", "object"}, + optional={"command"}, + ) + startup_module = _relative_path(startup.get("module")) + startup_object = startup.get("object") + if ( + startup_module.casefold() not in file_paths + or not isinstance(startup_object, str) + or not _PYTHON_OBJECT_RE.fullmatch(startup_object) + ): + raise MigrationContractError("invalid startup descriptor") + if "command" in startup: + command = _string_list( + startup["command"], + maximum_items=100, + allow_empty_items=False, + ) + if not command: + raise MigrationContractError("empty startup command") + + environment = value.get("environment") + if not isinstance(environment, dict): + raise MigrationContractError("invalid environment descriptor") + _exact_keys(environment, required={"required", "optional"}) + environment_keys: set[str] = set() + for field in ("required", "optional"): + keys = _string_list( + environment.get(field), + maximum_items=500, + allow_empty_items=False, + ) + for key in keys: + if not _ENVIRONMENT_KEY_RE.fullmatch(key) or key in environment_keys: + raise MigrationContractError("invalid environment key") + environment_keys.add(key) + + verification = value.get("verification") + if not isinstance(verification, dict): + raise MigrationContractError("invalid verification") + _exact_keys(verification, required={"status", "checks"}) + verification_status = verification.get("status") + checks = verification.get("checks") + if ( + verification_status not in {"passed", "failed", "degraded"} + or not isinstance(checks, list) + or len(checks) > 1_000 + ): + raise MigrationContractError("invalid verification") + failed_checks = 0 + for check in checks: + if not isinstance(check, dict): + raise MigrationContractError("invalid verification check") + _exact_keys( + check, + required={"name", "status"}, + optional={"detail"}, + ) + _text(check.get("name"), allow_empty=False, maximum=512) + if check.get("status") not in {"passed", "failed"}: + raise MigrationContractError("invalid verification check") + if check.get("status") == "failed": + failed_checks += 1 + if "detail" in check: + _text(check["detail"], maximum=_MAX_TEXT_LENGTH) + if ( + verification_status == "passed" + and failed_checks + or verification_status == "failed" + and failed_checks == 0 + ): + raise MigrationContractError("verification status is inconsistent") + + _string_list( + value.get("warnings"), + maximum_items=1_000, + allow_empty_items=False, + ) + + report = value.get("report") + if not isinstance(report, dict): + raise MigrationContractError("invalid report descriptor") + _exact_keys(report, required={"path"}) + report_path = _relative_path(report.get("path")) + if report_path.casefold() not in file_paths: + raise MigrationContractError("migration report is not in delivery files") + + artifact = value.get("artifact") + if not isinstance(artifact, dict): + raise MigrationContractError("invalid artifact descriptor") + _exact_keys(artifact, required={"path", "size", "sha256"}) + if artifact.get("path") != "migration-result.zip": + raise MigrationContractError("invalid artifact path") + _bounded_integer(artifact.get("size"), maximum=_MAX_ARTIFACT_BYTES) + _sha256(artifact.get("sha256")) + _timestamp_text(value.get("created_at")) + return {str(key): item for key, item in value.items()} + + +__all__ = [ + "MigrationContractError", + "validate_analysis_status", + "validate_analysis_result", + "validate_confirmation", + "validate_delivery_result", + "validate_delivery_status", + "validate_migration_request", + "validate_process_exit", + "validate_source_status", + "validate_stopped_status", +] diff --git a/frontend/server/migration/gateway.py b/frontend/server/migration/gateway.py new file mode 100644 index 00000000..d6a0b38b --- /dev/null +++ b/frontend/server/migration/gateway.py @@ -0,0 +1,811 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AgentKit Session, Exec, and File adapter used by Studio migration.""" + +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import requests +from agentkit.sdk.tools import types as tools_types +from agentkit.toolkit.cli.sandbox.sandbox_client import ( + SANDBOX_FILE_DOWNLOAD_ROUTE, + build_bash_exec_url, + build_file_url, +) + +from veadk.cli.agentkit_sandbox_region import ( + is_agentkit_resource_not_found, + sandbox_region_candidates, +) +from veadk.cli.agentkit_session_metadata import ( + build_create_session_request, + build_list_sessions_request, + call_session_client, + session_username, +) +from veadk.cli.frontend_skill_creator import _sandbox_model_config +from veadk.utils.cloud_provider import cloud_provider_from_env + +_TOOL_ID_ENV = "SANDBOX_DEV" +_DEVENV_IMAGE_ENV = "VEADK_DEVENV_IMAGE" +_EXPECTED_TOOL_TYPE = "DevEnv" +_TASK_ID_PREFIX = "migration-v1-" +_READ_TIMEOUT = (10, 120) +_WRITE_TIMEOUT = (10, 120) +_BASH_OUTPUT_ROUTE = "/v1/bash/output" +_RETRYABLE_HTTP_STATUSES = {408, 429, 500, 502, 503, 504} +_SESSION_READY_ATTEMPTS = 31 +_SESSION_READY_INTERVAL_SECONDS = 2 +_RELEASED_SESSION_STATUSES = { + "createfailed", + "deleted", + "deleting", + "error", + "expired", + "failed", +} + +logger = logging.getLogger(__name__) + + +class MigrationGatewayError(RuntimeError): + """A remote dependency failure with explicit retry semantics.""" + + def __init__( + self, + code: str, + message: str, + *, + status_code: int = 502, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.status_code = status_code + self.retryable = retryable + + +class MigrationRemoteFileNotFound(MigrationGatewayError): + def __init__(self, path: str) -> None: + super().__init__( + "MIGRATION_REMOTE_FILE_NOT_FOUND", + f"远端迁移文件不存在:{path}", + status_code=404, + ) + + +@dataclass(frozen=True) +class MigrationSandboxSession: + tool_id: str + session_id: str + task_id: str + endpoint: str + region: str + status: str + created_at: str + expire_at: str + owner_id: str + + @property + def released(self) -> bool: + return self.status.strip().lower() in _RELEASED_SESSION_STATUSES + + +class MigrationGateway(Protocol): + def capabilities(self) -> dict[str, object]: ... + + def create_session( + self, + *, + task_id: str, + owner_id: str, + creator_name: str, + display_name: str, + ttl_seconds: int, + ) -> MigrationSandboxSession: ... + + def list_sessions(self, owner_id: str) -> list[MigrationSandboxSession]: ... + + def find_session( + self, + task_id: str, + owner_id: str, + ) -> MigrationSandboxSession: ... + + def put_file( + self, + session: MigrationSandboxSession, + path: str, + content: bytes, + *, + media_type: str, + ) -> None: ... + + def get_file( + self, + session: MigrationSandboxSession, + path: str, + *, + max_bytes: int, + ) -> bytes: ... + + def execute_bash( + self, + session: MigrationSandboxSession, + command: str, + *, + operation: str, + timeout_seconds: int, + ) -> dict[str, object]: ... + + def delete_session(self, session: MigrationSandboxSession) -> None: ... + + +def _tool_has_model_credential(tool: Any) -> bool: + envs = { + str(getattr(item, "key", "") or ""): str( + getattr(item, "value", "") or "" + ).strip() + for item in (getattr(tool, "envs", None) or []) + if getattr(item, "key", None) + } + _, expected_base_url = _sandbox_model_config(cloud_provider_from_env()) + return bool( + envs.get("CODEX_MODEL") + and envs.get("CODEX_API_KEY") + and envs.get("CODEX_BASE_URL", "").rstrip("/") == expected_base_url.rstrip("/") + ) + + +class MigrationSandboxGateway: + """Stateless adapter for one-hour Dev Sandbox migration Sessions.""" + + def __init__( + self, + *, + tool_id: str | None = None, + region: str | None = None, + tools_client_factory: Callable[[str], Any], + ) -> None: + self._tool_id = (tool_id or os.getenv(_TOOL_ID_ENV) or "").strip() + self._regions = sandbox_region_candidates( + region or os.getenv("AGENTKIT_SANDBOX_REGION"), + provider=cloud_provider_from_env(), + ) + self._tools_client_factory = tools_client_factory + + def _client(self, region: str) -> Any: + return self._tools_client_factory(region) + + def _get_tool(self) -> tuple[Any, str]: + if not self._tool_id: + raise MigrationGatewayError( + "MIGRATION_DEVENV_NOT_CONFIGURED", + "管理员未配置 Dev Sandbox。", + status_code=503, + ) + request = tools_types.GetToolRequest(ToolId=self._tool_id) + for index, region in enumerate(self._regions): + try: + return self._client(region).get_tool(request), region + except Exception as error: + if is_agentkit_resource_not_found(error) and index + 1 < len( + self._regions + ): + continue + raise MigrationGatewayError( + "MIGRATION_DEVENV_UNAVAILABLE", + "Dev Sandbox 暂不可用,请联系管理员检查配置。", + status_code=503, + ) from error + raise MigrationGatewayError( + "MIGRATION_DEVENV_UNAVAILABLE", + "Dev Sandbox 暂不可用,请联系管理员检查配置。", + status_code=503, + ) + + def capabilities(self) -> dict[str, object]: + if not self._tool_id: + return {"enabled": False, "reason": "管理员未配置 Dev Sandbox。"} + try: + tool, _ = self._get_tool() + except MigrationGatewayError: + return { + "enabled": False, + "reason": "Dev Sandbox 暂不可用,请联系管理员检查配置。", + } + expected_image = (os.getenv(_DEVENV_IMAGE_ENV) or "").strip() + valid_tool = ( + str(getattr(tool, "tool_type", "") or "") == _EXPECTED_TOOL_TYPE + and str(getattr(tool, "status", "") or "") == "Ready" + ) + if expected_image: + valid_tool = valid_tool and ( + str(getattr(tool, "image_url", "") or "") == expected_image + ) + if not valid_tool: + return { + "enabled": False, + "reason": "Dev Sandbox 暂不可用,请联系管理员检查配置。", + } + if not _tool_has_model_credential(tool): + return { + "enabled": False, + "reason": "Dev Sandbox 模型配置不可用,请重新部署 Studio。", + } + return {"enabled": True, "reason": ""} + + @staticmethod + def _session( + value: Any, + *, + tool_id: str, + region: str, + owner_id: str = "", + task_id: str = "", + ) -> MigrationSandboxSession: + session_id = str(getattr(value, "session_id", "") or "").strip() + if not session_id: + raise MigrationGatewayError( + "MIGRATION_SESSION_RESPONSE_INVALID", + "Dev Sandbox 创建结果缺少 Session ID。", + ) + return MigrationSandboxSession( + tool_id=tool_id, + session_id=session_id, + task_id=str(getattr(value, "user_session_id", "") or task_id).strip(), + endpoint=str(getattr(value, "endpoint", "") or "").strip(), + region=region, + status=str(getattr(value, "status", "") or "Unknown").strip(), + created_at=str(getattr(value, "created_at", "") or "").strip(), + expire_at=str(getattr(value, "expire_at", "") or "").strip(), + owner_id=session_username(value) or owner_id, + ) + + def _list_region( + self, + region: str, + *, + owner_id: str | None, + task_id: str | None = None, + ) -> list[MigrationSandboxSession]: + next_token: str | None = None + seen_tokens: set[str] = set() + sessions: dict[str, MigrationSandboxSession] = {} + client = self._client(region) + for _ in range(100): + if task_id is None: + request = build_list_sessions_request( + tool_id=self._tool_id, + max_results=100, + next_token=next_token, + username=owner_id, + ) + else: + request = tools_types.ListSessionsRequest( + ToolId=self._tool_id, + MaxResults=100, + NextToken=next_token, + Filters=[ + tools_types.FiltersItemForListSessions( + Name="UserSessionId", + Values=[task_id], + ) + ], + ) + response = call_session_client(client, "list_sessions", request) + for value in response.session_infos or []: + session = self._session( + value, + tool_id=self._tool_id, + region=region, + ) + if not session.task_id.startswith(_TASK_ID_PREFIX): + continue + if task_id is not None and session.task_id != task_id: + continue + if owner_id is not None and session.owner_id != owner_id: + continue + sessions[session.session_id] = session + next_token = str(getattr(response, "next_token", "") or "").strip() or None + if next_token is None: + return sorted( + sessions.values(), + key=lambda item: item.created_at, + reverse=True, + ) + if next_token in seen_tokens: + raise MigrationGatewayError( + "MIGRATION_SESSION_LIST_INVALID", + "Dev Sandbox 会话分页响应异常。", + ) + seen_tokens.add(next_token) + raise MigrationGatewayError( + "MIGRATION_SESSION_LIST_INVALID", + "Dev Sandbox 会话分页超过安全上限。", + ) + + def list_sessions(self, owner_id: str) -> list[MigrationSandboxSession]: + if not self._tool_id: + return [] + for index, region in enumerate(self._regions): + try: + return self._list_region(region, owner_id=owner_id) + except Exception as error: + if is_agentkit_resource_not_found(error) and index + 1 < len( + self._regions + ): + continue + if isinstance(error, MigrationGatewayError): + raise + raise MigrationGatewayError( + "MIGRATION_SESSION_LIST_FAILED", + "暂时无法读取迁移会话。", + retryable=isinstance( + error, + (requests.ConnectionError, requests.Timeout), + ), + ) from error + return [] + + def find_session( + self, + task_id: str, + owner_id: str, + ) -> MigrationSandboxSession: + if not self._tool_id: + raise MigrationGatewayError( + "MIGRATION_DEVENV_NOT_CONFIGURED", + "管理员未配置 Dev Sandbox。", + status_code=503, + ) + for index, region in enumerate(self._regions): + try: + matches = self._list_region( + region, + owner_id=owner_id, + task_id=task_id, + ) + except Exception as error: + if is_agentkit_resource_not_found(error) and index + 1 < len( + self._regions + ): + continue + if isinstance(error, MigrationGatewayError): + raise + raise MigrationGatewayError( + "MIGRATION_SESSION_READ_FAILED", + "暂时无法读取迁移会话。", + retryable=isinstance( + error, + (requests.ConnectionError, requests.Timeout), + ), + ) from error + if not matches: + continue + if len(matches) != 1: + raise MigrationGatewayError( + "MIGRATION_SESSION_AMBIGUOUS", + "迁移会话状态异常,请联系管理员检查。", + ) + return matches[0] + raise MigrationGatewayError( + "MIGRATION_TASK_NOT_FOUND", + "迁移会话不存在或已过期。", + status_code=404, + ) + + def _wait_for_ready_session( + self, + region: str, + *, + task_id: str, + owner_id: str, + initial: MigrationSandboxSession | None = None, + ) -> MigrationSandboxSession | None: + current = initial + for attempt in range(_SESSION_READY_ATTEMPTS): + if current is not None: + status = current.status.strip().lower() + if current.endpoint and status in {"ready", "running"}: + return current + if current.released: + raise MigrationGatewayError( + "MIGRATION_SESSION_CREATE_FAILED", + "Dev Sandbox 创建后进入失败状态,请新建迁移。", + status_code=502, + ) + if attempt == _SESSION_READY_ATTEMPTS - 1: + break + if initial is None: + matches = self._list_region( + region, + owner_id=owner_id, + task_id=task_id, + ) + if len(matches) > 1: + raise MigrationGatewayError( + "MIGRATION_SESSION_AMBIGUOUS", + "迁移会话状态异常,请联系管理员检查。", + ) + current = matches[0] if matches else None + else: + response = call_session_client( + self._client(region), + "get_session", + tools_types.GetSessionRequest( + ToolId=self._tool_id, + SessionId=initial.session_id, + ), + ) + current = self._session( + response, + tool_id=self._tool_id, + region=region, + owner_id=owner_id, + task_id=task_id, + ) + if current is None or not ( + current.endpoint + and current.status.strip().lower() in {"ready", "running"} + ): + time.sleep(_SESSION_READY_INTERVAL_SECONDS) + return None + + def create_session( + self, + *, + task_id: str, + owner_id: str, + creator_name: str, + display_name: str, + ttl_seconds: int, + ) -> MigrationSandboxSession: + _, region = self._get_tool() + existing = self._list_region( + region, + owner_id=owner_id, + task_id=task_id, + ) + if len(existing) > 1: + raise MigrationGatewayError( + "MIGRATION_SESSION_AMBIGUOUS", + "迁移会话状态异常,请联系管理员检查。", + ) + if existing: + ready = self._wait_for_ready_session( + region, + task_id=task_id, + owner_id=owner_id, + initial=existing[0], + ) + if ready is None: + raise MigrationGatewayError( + "MIGRATION_SESSION_CREATE_INCOMPLETE", + "Dev Sandbox 未在时限内就绪,请刷新迁移列表确认。", + ) + return ready + request = build_create_session_request( + tool_id=self._tool_id, + ttl_seconds=ttl_seconds, + user_session_id=task_id, + display_name=display_name, + username=owner_id, + creator_name=creator_name, + ) + try: + response = self._client(region).create_session(request) + except Exception as error: + try: + recovered = self._wait_for_ready_session( + region, + task_id=task_id, + owner_id=owner_id, + ) + except Exception as recovery_error: # noqa: BLE001 + logger.warning( + "Migration Session recovery query failed task_id=%s error_type=%s", + task_id, + type(recovery_error).__name__, + ) + recovered = None + if recovered is not None: + return recovered + raise MigrationGatewayError( + "MIGRATION_SESSION_CREATE_UNCERTAIN", + "Dev Sandbox 创建结果无法确认,请刷新迁移列表后再操作。", + status_code=502, + retryable=False, + ) from error + session = self._session( + response, + tool_id=self._tool_id, + region=region, + owner_id=owner_id, + task_id=task_id, + ) + if session.endpoint and session.status.strip().lower() in {"ready", "running"}: + return session + ready = self._wait_for_ready_session( + region, + task_id=task_id, + owner_id=owner_id, + initial=session, + ) + if ready is None: + raise MigrationGatewayError( + "MIGRATION_SESSION_CREATE_INCOMPLETE", + "Dev Sandbox 未在时限内就绪,请刷新迁移列表确认。", + ) + return ready + + @staticmethod + def _require_endpoint(session: MigrationSandboxSession) -> str: + if session.released or not session.endpoint: + raise MigrationGatewayError( + "MIGRATION_SESSION_EXPIRED", + "Dev Sandbox 已清理,无法继续操作。", + status_code=410, + ) + return session.endpoint + + def put_file( + self, + session: MigrationSandboxSession, + path: str, + content: bytes, + *, + media_type: str, + ) -> None: + endpoint = self._require_endpoint(session) + try: + response = requests.post( + build_file_url(endpoint, "/v1/file/upload"), + data={"path": path}, + files={"file": (path.rsplit("/", 1)[-1], content, media_type)}, + timeout=_WRITE_TIMEOUT, + ) + except (requests.ConnectionError, requests.Timeout) as error: + raise MigrationGatewayError( + "MIGRATION_REMOTE_WRITE_UNCERTAIN", + "写入 Dev Sandbox 的结果无法确认,请刷新迁移状态。", + retryable=False, + ) from error + if response.status_code >= 400: + raise MigrationGatewayError( + "MIGRATION_REMOTE_WRITE_FAILED", + "写入 Dev Sandbox 失败。", + status_code=502, + retryable=False, + ) + + def get_file( + self, + session: MigrationSandboxSession, + path: str, + *, + max_bytes: int, + ) -> bytes: + endpoint = self._require_endpoint(session) + try: + response = requests.get( + build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE), + params={"path": path, "change_policy": "abort"}, + timeout=_READ_TIMEOUT, + stream=True, + ) + except (requests.ConnectionError, requests.Timeout) as error: + raise MigrationGatewayError( + "MIGRATION_REMOTE_READ_FAILED", + "读取 Dev Sandbox 失败,请稍后重试。", + retryable=True, + ) from error + if response.status_code == 404: + response.close() + raise MigrationRemoteFileNotFound(path) + if response.status_code >= 400: + retryable = response.status_code in _RETRYABLE_HTTP_STATUSES + response.close() + raise MigrationGatewayError( + "MIGRATION_REMOTE_READ_FAILED", + "读取 Dev Sandbox 失败,请稍后重试。", + retryable=retryable, + ) + declared = response.headers.get("content-length") + if declared: + try: + if int(declared) > max_bytes: + response.close() + raise MigrationGatewayError( + "MIGRATION_REMOTE_FILE_TOO_LARGE", + "远端迁移文件超过读取上限。", + ) + except ValueError: + pass + content = bytearray() + try: + for chunk in response.iter_content(1024 * 1024): + if not chunk: + continue + if len(content) + len(chunk) > max_bytes: + raise MigrationGatewayError( + "MIGRATION_REMOTE_FILE_TOO_LARGE", + "远端迁移文件超过读取上限。", + ) + content.extend(chunk) + finally: + response.close() + return bytes(content) + + def execute_bash( + self, + session: MigrationSandboxSession, + command: str, + *, + operation: str, + timeout_seconds: int, + ) -> dict[str, object]: + endpoint = self._require_endpoint(session) + deadline = time.monotonic() + timeout_seconds + 30 + + def response_data(response: requests.Response) -> dict[str, object]: + if response.status_code >= 400: + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_FAILED", + "Dev Sandbox 操作失败。", + retryable=False, + ) + try: + payload = response.json() + except ValueError as error: + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_INVALID", + "Dev Sandbox 返回了无效响应。", + ) from error + data = payload.get("data", payload) if isinstance(payload, dict) else {} + if not isinstance(data, dict): + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_INVALID", + "Dev Sandbox 返回了无效响应。", + ) + return {str(key): value for key, value in data.items()} + + def command_state(data: dict[str, object]) -> tuple[str, object]: + command = data.get("command") + command_data = command if isinstance(command, dict) else {} + status = str(command_data.get("status") or data.get("status") or "").lower() + exit_code = command_data.get( + "exit_code", + data.get("exit_code", data.get("exitCode")), + ) + return status, exit_code + + try: + response = requests.post( + build_bash_exec_url(endpoint), + json={ + "timeout": min(timeout_seconds, 30), + "hard_timeout": timeout_seconds, + "command": command, + }, + timeout=(10, min(timeout_seconds + 30, 180)), + ) + except (requests.ConnectionError, requests.Timeout) as error: + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_UNCERTAIN", + "Dev Sandbox 操作结果无法确认,请刷新迁移状态。", + retryable=False, + ) from error + data = response_data(response) + status, exit_code = command_state(data) + session_id = str(data.get("session_id") or "").strip() + command_id = str(data.get("command_id") or "").strip() + offset = data.get("offset", 0) + stderr_offset = data.get("stderr_offset", 0) + + while status == "running": + if ( + not session_id + or not command_id + or isinstance(offset, bool) + or not isinstance(offset, int) + or offset < 0 + or isinstance(stderr_offset, bool) + or not isinstance(stderr_offset, int) + or stderr_offset < 0 + ): + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_INVALID", + "Dev Sandbox 返回了无效的命令轮询状态。", + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_TIMEOUT", + "Dev Sandbox 操作超过执行时限。", + retryable=False, + ) + wait_timeout = min(30, max(1, int(remaining))) + try: + response = requests.post( + build_file_url(endpoint, _BASH_OUTPUT_ROUTE), + json={ + "session_id": session_id, + "command_id": command_id, + "offset": offset, + "stderr_offset": stderr_offset, + "wait": True, + "wait_timeout": wait_timeout, + }, + timeout=(10, wait_timeout + 10), + ) + except (requests.ConnectionError, requests.Timeout) as error: + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_UNCERTAIN", + "Dev Sandbox 操作结果无法确认,请刷新迁移状态。", + retryable=False, + ) from error + data = response_data(response) + status, exit_code = command_state(data) + offset = data.get("offset", offset) + stderr_offset = data.get("stderr_offset", stderr_offset) + + if status != "completed" or isinstance(exit_code, bool) or exit_code != 0: + logger.warning( + "Migration Sandbox command failed operation=%s status=%s exit_code=%s", + operation, + status or "missing", + exit_code, + ) + raise MigrationGatewayError( + "MIGRATION_REMOTE_EXEC_FAILED", + "Dev Sandbox 操作未成功完成。", + retryable=False, + ) + data["status"] = status + data["exit_code"] = exit_code + return data + + def delete_session(self, session: MigrationSandboxSession) -> None: + try: + self._client(session.region).delete_session( + tools_types.DeleteSessionRequest( + ToolId=session.tool_id, + SessionId=session.session_id, + ) + ) + except Exception as error: + if is_agentkit_resource_not_found(error): + return + raise MigrationGatewayError( + "MIGRATION_SESSION_DELETE_FAILED", + "删除迁移会话失败,请刷新后重试。", + retryable=False, + ) from error + + +__all__ = [ + "MigrationGateway", + "MigrationGatewayError", + "MigrationRemoteFileNotFound", + "MigrationSandboxGateway", + "MigrationSandboxSession", +] diff --git a/frontend/server/migration/models.py b/frontend/server/migration/models.py new file mode 100644 index 00000000..3fd042f6 --- /dev/null +++ b/frontend/server/migration/models.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validated request contracts for Studio project migration.""" + +from __future__ import annotations + +import re +from pathlib import PurePosixPath +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +MigrationFramework = Literal[ + "langchain", + "langgraph", + "adk", + "strands", + "agentcore", + "dify", + "any", +] + +MIGRATION_FRAMEWORKS: tuple[MigrationFramework, ...] = ( + "langchain", + "langgraph", + "adk", + "strands", + "agentcore", + "dify", + "any", +) +STRUCTURED_MIGRATION_FRAMEWORKS: frozenset[str] = frozenset( + {"langchain", "langgraph", "adk", "strands", "agentcore"} +) +_SOURCE_FILE_NAME_RE = re.compile(r"^[^/\\\x00-\x1f]{1,255}\.zip$", re.IGNORECASE) +_APP_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") +_TASK_ID_RE = re.compile(r"^migration-v1-[0-9a-f]{32}$") +_ENTRY_RE = re.compile(r"^[A-Za-z0-9_./-]+\.(?:py|json)(?::[A-Za-z_][A-Za-z0-9_]*)?$") + + +def is_valid_structured_entry(value: object) -> bool: + if not isinstance(value, str) or not _ENTRY_RE.fullmatch(value): + return False + path_value = value.split(":", 1)[0] + path = PurePosixPath(path_value) + return ( + not path.is_absolute() + and "." not in path.parts + and ".." not in path.parts + and path.as_posix() == path_value + ) + + +class CreateMigrationTaskBody(BaseModel): + task_id: str | None = Field(default=None, alias="taskId", max_length=45) + source_file_name: str = Field(alias="sourceFileName", min_length=1, max_length=255) + instruction: str = Field(default="", max_length=20_000) + + model_config = {"populate_by_name": True, "extra": "forbid"} + + @model_validator(mode="after") + def normalize(self) -> CreateMigrationTaskBody: + self.task_id = (self.task_id or "").strip() or None + self.source_file_name = self.source_file_name.strip() + self.instruction = self.instruction.strip() + if self.task_id is not None and not _TASK_ID_RE.fullmatch(self.task_id): + raise ValueError("迁移会话 ID 无效") + if not _SOURCE_FILE_NAME_RE.fullmatch(self.source_file_name): + raise ValueError("请选择名称有效的 ZIP 文件") + return self + + +class ConfirmMigrationBody(BaseModel): + framework: MigrationFramework + entry: str | None = Field(default=None, max_length=512) + app_name: str = Field(alias="appName", min_length=1, max_length=64) + instruction: str = Field(default="", max_length=20_000) + answers: dict[str, str] = Field(default_factory=dict) + + model_config = {"populate_by_name": True, "extra": "forbid"} + + @model_validator(mode="after") + def normalize(self) -> ConfirmMigrationBody: + self.entry = (self.entry or "").strip() or None + self.app_name = self.app_name.strip() + self.instruction = self.instruction.strip() + if not _APP_NAME_RE.fullmatch(self.app_name): + raise ValueError( + "Agent 名称必须以字母开头,且只能包含字母、数字、下划线和连字符" + ) + if self.framework in STRUCTURED_MIGRATION_FRAMEWORKS: + if not is_valid_structured_entry(self.entry): + raise ValueError("Structured 迁移必须确认有效的项目入口") + elif self.entry is not None: + raise ValueError("Dify/Any 迁移不接受 Structured 项目入口") + if len(self.answers) > 50: + raise ValueError("待确认问题不能超过 50 个") + normalized_answers: dict[str, str] = {} + for key, value in self.answers.items(): + normalized_key = key.strip() + normalized_value = value.strip() + if not normalized_key or len(normalized_key) > 128: + raise ValueError("待确认问题 ID 无效") + if len(normalized_value) > 4_000: + raise ValueError("单个确认答案不能超过 4000 个字符") + normalized_answers[normalized_key] = normalized_value + self.answers = normalized_answers + return self + + +__all__ = [ + "MIGRATION_FRAMEWORKS", + "STRUCTURED_MIGRATION_FRAMEWORKS", + "ConfirmMigrationBody", + "CreateMigrationTaskBody", + "MigrationFramework", + "is_valid_structured_entry", +] diff --git a/frontend/server/migration/routes.py b/frontend/server/migration/routes.py new file mode 100644 index 00000000..87bcac3c --- /dev/null +++ b/frontend/server/migration/routes.py @@ -0,0 +1,276 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI boundary for Studio project migration.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + +from fastapi import HTTPException, Query, Request +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import Response + +from .models import ConfirmMigrationBody, CreateMigrationTaskBody +from .service import ( + MIGRATION_UPLOAD_MAX_BYTES, + MigrationError, + MigrationService, +) + +logger = logging.getLogger(__name__) +_ZIP_CONTENT_TYPES = { + "application/zip", + "application/x-zip-compressed", + "application/octet-stream", +} + + +def mount_migration_routes( + app: Any, + service: MigrationService, + *, + owner_resolver: Callable[[Request], str], + creator_resolver: Callable[[Request], str], +) -> None: + async def invoke( + operation: str, + call: Callable[[], Any], + *, + task_id: str = "", + ) -> Any: + try: + return await run_in_threadpool(call) + except MigrationError as error: + logger.warning( + "Studio migration request failed operation=%s task_id=%s " + "code=%s retryable=%s", + operation, + task_id or "none", + error.code, + str(error.retryable).lower(), + ) + raise HTTPException( + status_code=error.status_code, + detail=error.detail(), + ) from error + except Exception as error: + logger.exception( + "Studio migration internal failure operation=%s task_id=%s " + "error_type=%s", + operation, + task_id or "none", + type(error).__name__, + ) + internal = MigrationError( + "MIGRATION_INTERNAL", + "迁移服务异常,请刷新状态后重试。", + status_code=500, + retryable=False, + ) + raise HTTPException( + status_code=internal.status_code, + detail=internal.detail(), + ) from error + + @app.get("/web/migrations/capabilities") + async def capabilities(request: Request) -> dict[str, object]: + owner_resolver(request) + return await invoke("capabilities", service.capabilities) + + @app.get("/web/migrations/tasks") + async def list_tasks(request: Request) -> dict[str, list[dict[str, object]]]: + owner_id = owner_resolver(request) + return await invoke( + "list_tasks", + lambda: service.list_tasks(owner_id), + ) + + @app.post("/web/migrations/tasks") + async def create_task( + body: CreateMigrationTaskBody, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + creator_name = creator_resolver(request) + return await invoke( + "create_task", + lambda: service.create_task(body, owner_id, creator_name), + ) + + @app.put("/web/migrations/tasks/{task_id}/source") + async def upload_source( + task_id: str, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + content_type = ( + request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + ) + if content_type not in _ZIP_CONTENT_TYPES: + error = MigrationError( + "MIGRATION_SOURCE_CONTENT_TYPE_INVALID", + "请选择 ZIP 格式的本地项目文件。", + status_code=415, + ) + raise HTTPException(error.status_code, detail=error.detail()) + declared = request.headers.get("content-length") + if declared is not None: + try: + declared_bytes = int(declared) + if declared_bytes < 0: + raise ValueError("negative content length") + except ValueError as error: + invalid = MigrationError( + "MIGRATION_SOURCE_LENGTH_INVALID", + "项目 ZIP 大小格式无效。", + status_code=400, + ) + raise HTTPException( + invalid.status_code, + detail=invalid.detail(), + ) from error + if declared_bytes > MIGRATION_UPLOAD_MAX_BYTES: + too_large = MigrationError( + "MIGRATION_SOURCE_TOO_LARGE", + "项目 ZIP 不能超过 50 MiB。", + status_code=413, + ) + raise HTTPException( + too_large.status_code, + detail=too_large.detail(), + ) + content = bytearray() + async for chunk in request.stream(): + if len(content) + len(chunk) > MIGRATION_UPLOAD_MAX_BYTES: + too_large = MigrationError( + "MIGRATION_SOURCE_TOO_LARGE", + "项目 ZIP 不能超过 50 MiB。", + status_code=413, + ) + raise HTTPException( + too_large.status_code, + detail=too_large.detail(), + ) + content.extend(chunk) + return await invoke( + "upload_source", + lambda: service.upload_source(task_id, owner_id, bytes(content)), + task_id=task_id, + ) + + @app.get("/web/migrations/tasks/{task_id}") + async def get_task( + task_id: str, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + return await invoke( + "get_task", + lambda: service.get_task(task_id, owner_id), + task_id=task_id, + ) + + @app.post("/web/migrations/tasks/{task_id}/confirm") + async def confirm( + task_id: str, + body: ConfirmMigrationBody, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + return await invoke( + "confirm", + lambda: service.confirm(task_id, owner_id, body), + task_id=task_id, + ) + + @app.post("/web/migrations/tasks/{task_id}/stop") + async def stop( + task_id: str, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + return await invoke( + "stop", + lambda: service.stop(task_id, owner_id), + task_id=task_id, + ) + + @app.get("/web/migrations/tasks/{task_id}/artifact") + async def artifact( + task_id: str, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + return await invoke( + "artifact", + lambda: service.artifact(task_id, owner_id), + task_id=task_id, + ) + + @app.get("/web/migrations/tasks/{task_id}/download") + async def download( + task_id: str, + request: Request, + ) -> Response: + owner_id = owner_resolver(request) + content, filename = await invoke( + "download", + lambda: service.download(task_id, owner_id), + task_id=task_id, + ) + return Response( + content=content, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + @app.get("/web/migrations/tasks/{task_id}/artifact/file") + async def preview_file( + task_id: str, + request: Request, + path: str = Query(min_length=1, max_length=4096), + ) -> Response: + owner_id = owner_resolver(request) + content, media_type = await invoke( + "preview_file", + lambda: service.preview_file(task_id, owner_id, path), + task_id=task_id, + ) + return Response( + content=content, + media_type=media_type, + headers={"Cache-Control": "no-store"}, + ) + + @app.delete("/web/migrations/tasks/{task_id}") + async def delete( + task_id: str, + request: Request, + ) -> dict[str, bool]: + owner_id = owner_resolver(request) + await invoke( + "delete", + lambda: service.delete(task_id, owner_id), + task_id=task_id, + ) + return {"deleted": True} + + +__all__ = ["mount_migration_routes"] diff --git a/frontend/server/migration/service.py b/frontend/server/migration/service.py new file mode 100644 index 00000000..8142ed08 --- /dev/null +++ b/frontend/server/migration/service.py @@ -0,0 +1,2018 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stateless Studio orchestration for migrations inside Dev Sandbox Sessions.""" + +from __future__ import annotations + +import hashlib +import io +import json +import logging +import mimetypes +import re +import shlex +import stat +import time +import uuid +import zipfile +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath + +from frontend.server.deployment_source import ( + DeploymentSourceError, + extract_migration_source, +) + +from .contracts import ( + MigrationContractError, + validate_analysis_result, + validate_analysis_status, + validate_confirmation, + validate_delivery_result, + validate_delivery_status, + validate_migration_request, + validate_process_exit, + validate_source_status, + validate_stopped_status, +) +from .gateway import ( + MigrationGateway, + MigrationGatewayError, + MigrationRemoteFileNotFound, + MigrationSandboxSession, +) +from .models import ( + MIGRATION_FRAMEWORKS, + STRUCTURED_MIGRATION_FRAMEWORKS, + ConfirmMigrationBody, + CreateMigrationTaskBody, +) + +MIGRATION_ROOT = "/home/gem/.studio/migration/v1" +MIGRATION_SESSION_TTL_SECONDS = 60 * 60 +MIGRATION_UPLOAD_MAX_BYTES = 50 * 1024 * 1024 +_MAX_EXPANDED_BYTES = 1024 * 1024 * 1024 +_MAX_ARCHIVE_FILES = 20_000 +_MAX_ARCHIVE_PATH_BYTES = 4 * 1024 +_MAX_ARCHIVE_DEPTH = 64 +_MAX_JSON_BYTES = 16 * 1024 * 1024 +_MAX_PROVENANCE_BYTES = 64 * 1024 +_MAX_ARTIFACT_BYTES = 512 * 1024 * 1024 +_MAX_PREVIEW_BYTES = 2 * 1024 * 1024 +_FILE_OPERATION_TIMEOUT_SECONDS = 300 +_TASK_ID_RE = re.compile(r"^migration-v1-[0-9a-f]{32}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_ACTIVE_STATES = {"analyzing", "migrating", "validating", "packaging"} +_REQUEST_PATH = f"{MIGRATION_ROOT}/request.json" +_SOURCE_PATH = f"{MIGRATION_ROOT}/input/source.zip" +_PROJECT_PATH = f"{MIGRATION_ROOT}/input/project" +_SOURCE_STATUS_PATH = f"{MIGRATION_ROOT}/state/source.json" +_ANALYSIS_STATUS_PATH = f"{MIGRATION_ROOT}/state/analysis-status.json" +_ANALYSIS_RESULT_PATH = f"{MIGRATION_ROOT}/state/analysis.json" +_ANALYSIS_PROMPT_PATH = f"{MIGRATION_ROOT}/state/analysis-prompt.md" +_ANALYSIS_SCHEMA_PATH = f"{MIGRATION_ROOT}/state/analysis-schema.json" +_ANALYSIS_PROCESS_EXIT_PATH = f"{MIGRATION_ROOT}/state/analysis-process-exit.json" +_CONFIRMATION_PATH = f"{MIGRATION_ROOT}/state/confirmation.json" +_INSTRUCTION_PATH = f"{MIGRATION_ROOT}/state/migration-instructions.md" +_STOPPED_PATH = f"{MIGRATION_ROOT}/state/stopped.json" +_PROCESS_EXIT_PATH = f"{MIGRATION_ROOT}/state/migration-process-exit.json" +_DELIVERY_STATUS_PATH = f"{MIGRATION_ROOT}/delivery/migration-status.json" +_DELIVERY_RESULT_PATH = f"{MIGRATION_ROOT}/delivery/migration-result.json" +_DELIVERY_ARTIFACT_PATH = f"{MIGRATION_ROOT}/delivery/migration-result.zip" + +logger = logging.getLogger(__name__) + + +class MigrationError(RuntimeError): + """A bounded migration failure safe to expose through Studio.""" + + def __init__( + self, + code: str, + message: str, + *, + status_code: int = 400, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.status_code = status_code + self.retryable = retryable + + def detail(self) -> dict[str, object]: + return { + "code": self.code, + "message": str(self), + "retryable": self.retryable, + } + + +@dataclass(frozen=True) +class SourceArchiveSummary: + file_count: int + expanded_bytes: int + + +def _has_control_character(value: str) -> bool: + return any(ord(character) < 32 or ord(character) == 127 for character in value) + + +def validate_source_archive(content: bytes) -> SourceArchiveSummary: + """Validate ZIP structure without assuming a source framework or root layout.""" + if not content: + raise MigrationError( + "MIGRATION_SOURCE_EMPTY", + "上传的 ZIP 文件为空。", + status_code=422, + ) + if len(content) > MIGRATION_UPLOAD_MAX_BYTES: + raise MigrationError( + "MIGRATION_SOURCE_TOO_LARGE", + "项目 ZIP 不能超过 50 MiB。", + status_code=413, + ) + seen: set[str] = set() + file_count = 0 + expanded_bytes = 0 + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + for info in archive.infolist(): + raw_path = info.filename + path = PurePosixPath(raw_path) + normalized = path.as_posix() + if ( + not raw_path + or "\\" in raw_path + or _has_control_character(raw_path) + or path.is_absolute() + or ".." in path.parts + or _utf8_length(normalized) > _MAX_ARCHIVE_PATH_BYTES + or len(path.parts) > _MAX_ARCHIVE_DEPTH + ): + raise MigrationError( + "MIGRATION_SOURCE_UNSAFE_PATH", + f"项目 ZIP 包含不安全路径:{raw_path}", + status_code=422, + ) + folded = normalized.casefold() + if folded in seen: + raise MigrationError( + "MIGRATION_SOURCE_DUPLICATE_PATH", + f"项目 ZIP 包含重复路径:{raw_path}", + status_code=422, + ) + seen.add(folded) + mode = info.external_attr >> 16 + if stat.S_IFMT(mode) == stat.S_IFLNK: + raise MigrationError( + "MIGRATION_SOURCE_SYMLINK", + f"项目 ZIP 不允许符号链接:{raw_path}", + status_code=422, + ) + if info.flag_bits & 0x1: + raise MigrationError( + "MIGRATION_SOURCE_ENCRYPTED", + "项目 ZIP 不支持加密文件。", + status_code=422, + ) + if info.is_dir(): + continue + file_count += 1 + expanded_bytes += info.file_size + if file_count > _MAX_ARCHIVE_FILES: + raise MigrationError( + "MIGRATION_SOURCE_FILE_COUNT", + f"项目 ZIP 文件数不能超过 {_MAX_ARCHIVE_FILES} 个。", + status_code=413, + ) + if expanded_bytes > _MAX_EXPANDED_BYTES: + raise MigrationError( + "MIGRATION_SOURCE_EXPANDED_TOO_LARGE", + "项目 ZIP 解压后不能超过 1 GiB。", + status_code=413, + ) + except zipfile.BadZipFile as error: + raise MigrationError( + "MIGRATION_SOURCE_INVALID", + "请选择有效的 ZIP 项目文件。", + status_code=422, + ) from error + if file_count == 0: + raise MigrationError( + "MIGRATION_SOURCE_EMPTY", + "项目 ZIP 中没有可迁移文件。", + status_code=422, + ) + return SourceArchiveSummary( + file_count=file_count, + expanded_bytes=expanded_bytes, + ) + + +def _utf8_length(value: str) -> int: + return len(value.encode("utf-8")) + + +def _timestamp(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.timestamp() + + +def _iso_timestamp(value: float) -> str: + return datetime.fromtimestamp(value, tz=UTC).isoformat().replace("+00:00", "Z") + + +def _json_bytes(value: object) -> bytes: + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def _atomic_json_command(path: str, value: object) -> str: + temporary = f"{path}.tmp" + return ( + f"printf '%s\\n' {shlex.quote(json.dumps(value, ensure_ascii=False))} " + f"> {shlex.quote(temporary)} && mv {shlex.quote(temporary)} {shlex.quote(path)}" + ) + + +def _accept_request_command(candidate_path: str, expected_sha256: str) -> str: + script = f""" +import fcntl +import hashlib +import json +import os +from pathlib import Path + +root = Path({MIGRATION_ROOT!r}) +candidate = Path({candidate_path!r}) +request = Path({_REQUEST_PATH!r}) +lock = root / "state" / "request-accept.lock" +expected_sha256 = {expected_sha256!r} +immutable_fields = ( + "schema_version", + "task_id", + "source_file_name", + "instruction", + "session_ttl_seconds", +) + +root.mkdir(parents=True, exist_ok=True) +lock.parent.mkdir(parents=True, exist_ok=True) +if not candidate.is_file(): + raise RuntimeError("migration request candidate is missing") +candidate_content = candidate.read_bytes() +if hashlib.sha256(candidate_content).hexdigest() != expected_sha256: + raise RuntimeError("migration request candidate digest does not match") +candidate_value = json.loads(candidate_content) + +fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600) +try: + fcntl.flock(fd, fcntl.LOCK_EX) + if request.exists(): + current = json.loads(request.read_text(encoding="utf-8")) + if any(current.get(field) != candidate_value.get(field) for field in immutable_fields): + raise RuntimeError("migration request conflicts with the accepted request") + candidate.unlink(missing_ok=True) + else: + candidate.replace(request) +finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) +""" + return "set -euo pipefail\npython3 - <<'PY'\n" + script.strip() + "\nPY" + + +def _analysis_schema() -> dict[str, object]: + evidence = { + "type": "object", + "additionalProperties": False, + "required": ["path", "line", "reason"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4_096, + "pattern": ( + r"^(?!/)(?!.*(?:^|/)\.{1,2}(?:/|$))" + r"(?!.*//)(?!.*\\)[^\x00-\x1f\x7f]+$" + ), + }, + "line": {"type": "integer", "minimum": 1}, + "reason": {"type": "string", "minLength": 1, "maxLength": 4_000}, + }, + } + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "required": [ + "schema_version", + "summary", + "frameworks", + "recommended", + "entries", + "boundary", + "questions", + "warnings", + ], + "properties": { + "schema_version": {"const": 1}, + "summary": {"type": "string", "maxLength": 20_000}, + "frameworks": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id", "confidence", "evidence"], + "properties": { + "id": { + "enum": list(MIGRATION_FRAMEWORKS), + }, + "confidence": {"enum": ["high", "medium", "low"]}, + "evidence": { + "type": "array", + "maxItems": 100, + "items": evidence, + }, + }, + }, + }, + "recommended": { + "type": "object", + "additionalProperties": False, + "required": ["framework", "entry", "reason"], + "properties": { + "framework": {"enum": list(MIGRATION_FRAMEWORKS)}, + "entry": { + "type": ["string", "null"], + "maxLength": 512, + }, + "reason": {"type": "string", "maxLength": 4_000}, + }, + }, + "entries": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["value", "framework", "evidence"], + "properties": { + "value": {"type": "string", "maxLength": 512}, + "framework": {"enum": list(MIGRATION_FRAMEWORKS)}, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 4_000, + }, + }, + }, + }, + "boundary": { + "type": "object", + "additionalProperties": False, + "required": ["include", "exclude"], + "properties": { + "include": { + "type": "array", + "maxItems": 200, + "items": {"type": "string", "maxLength": 4_000}, + }, + "exclude": { + "type": "array", + "maxItems": 200, + "items": {"type": "string", "maxLength": 4_000}, + }, + }, + }, + "questions": { + "type": "array", + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id", "prompt", "required"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 4_000, + }, + "required": {"type": "boolean"}, + }, + }, + }, + "warnings": { + "type": "array", + "maxItems": 100, + "items": {"type": "string", "maxLength": 4_000}, + }, + }, + } + + +def _analysis_prompt(request: dict[str, object]) -> str: + instruction = str(request.get("instruction") or "").strip() + return f"""你是 AgentKit 项目迁移分析器。此阶段只分析,不执行迁移。 + +## 安全与操作边界 + +- 只读检查 `{_PROJECT_PATH}`,禁止修改、安装依赖、联网或执行来源项目代码。 +- 不要调用 `ak migrate inspect`,也不要开始任何迁移。 +- 通过依赖文件、导入、对象定义、配置和调用关系识别框架、候选入口与迁移边界。 +- 每个结论必须给出文件路径、行号和理由;证据不足时降低置信度,不得猜测。 +- Structured 候选仅限 langchain、langgraph、adk、strands、agentcore。 +- Dify 导出选择 dify;无法可靠归类、需要 Agentic 改写的项目选择 any。 +- 最终迁移方式必须由用户选择并确认,本阶段只给建议和待确认问题。 +- 用户使用什么语言,你就使用什么语言;JSON 字段名保持 Schema 约定。 +- 最终响应必须严格符合提供的 JSON Schema,不要输出 Markdown 围栏或额外文字。 + +## 用户补充要求 + +{instruction or "用户未补充额外要求。"} +""" + + +def _prepare_source_command( + *, + candidate_path: str, + source_sha256: str, + source_size: int, + summary: SourceArchiveSummary, +) -> str: + script = f""" +import hashlib +import json +import os +import shutil +import stat +import zipfile +from pathlib import Path, PurePosixPath + +root = Path({MIGRATION_ROOT!r}) +candidate = Path({candidate_path!r}) +source = Path({_SOURCE_PATH!r}) +project = Path({_PROJECT_PATH!r}) +marker = Path({_SOURCE_STATUS_PATH!r}) +lock = root / "state" / "source-accept.lock" +expected_sha = {source_sha256!r} +expected_size = {source_size} +expected_files = {summary.file_count} +expected_expanded = {summary.expanded_bytes} +max_files = {_MAX_ARCHIVE_FILES} +max_bytes = {_MAX_EXPANDED_BYTES} +max_path_bytes = {_MAX_ARCHIVE_PATH_BYTES} +max_depth = {_MAX_ARCHIVE_DEPTH} + +for relative in ("input", "state", "events", "logs", "workspace", "work", "output", "delivery"): + (root / relative).mkdir(parents=True, exist_ok=True) + +if marker.exists(): + current = json.loads(marker.read_text(encoding="utf-8")) + if current.get("sha256") == expected_sha: + candidate.unlink(missing_ok=True) + raise SystemExit(0) + raise RuntimeError("migration source is immutable after acceptance") + +try: + lock.mkdir() +except FileExistsError as error: + raise RuntimeError("migration source acceptance is already running") from error + +extracting = root / "input" / f".extract-{{expected_sha}}" +normalized = root / "input" / f".project-{{expected_sha}}" +try: + if not candidate.is_file() or candidate.stat().st_size != expected_size: + raise RuntimeError("uploaded source size does not match") + digest = hashlib.sha256(candidate.read_bytes()).hexdigest() + if digest != expected_sha: + raise RuntimeError("uploaded source digest does not match") + shutil.rmtree(extracting, ignore_errors=True) + shutil.rmtree(normalized, ignore_errors=True) + extracting.mkdir() + files = 0 + expanded = 0 + with zipfile.ZipFile(candidate) as archive: + for info in archive.infolist(): + raw = info.filename + path = PurePosixPath(raw) + if ( + not raw + or "\\\\" in raw + or any(ord(character) < 32 or ord(character) == 127 for character in raw) + or path.is_absolute() + or ".." in path.parts + or len(raw.encode("utf-8")) > max_path_bytes + or len(path.parts) > max_depth + ): + raise RuntimeError("unsafe archive path") + if stat.S_IFMT(info.external_attr >> 16) == stat.S_IFLNK: + raise RuntimeError("archive links are not allowed") + target = extracting.joinpath(*path.parts) + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + files += 1 + expanded += info.file_size + if files > max_files or expanded > max_bytes: + raise RuntimeError("expanded archive exceeds limits") + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info) as source_file, target.open("wb") as output: + shutil.copyfileobj(source_file, output, length=1024 * 1024) + if files != expected_files or expanded != expected_expanded: + raise RuntimeError("uploaded source metadata changed during transfer") + children = list(extracting.iterdir()) + if len(children) == 1 and children[0].is_dir(): + children[0].rename(normalized) + extracting.rmdir() + else: + extracting.rename(normalized) + if project.exists(): + raise RuntimeError("migration project is immutable after extraction") + normalized.rename(project) + candidate.replace(source) + payload = {{ + "schema_version": 1, + "sha256": expected_sha, + "size": expected_size, + "file_count": files, + "expanded_bytes": expanded, + }} + temporary = marker.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + temporary.replace(marker) +finally: + shutil.rmtree(extracting, ignore_errors=True) + shutil.rmtree(normalized, ignore_errors=True) + try: + lock.rmdir() + except OSError: + pass +""" + return "set -euo pipefail\npython3 - <<'PY'\n" + script.strip() + "\nPY" + + +def _start_analysis_command() -> str: + running_status = { + "schema_version": 1, + "state": "analyzing", + "message": "正在分析项目框架、入口与迁移边界", + } + ready_status = { + "schema_version": 1, + "state": "ready", + "message": "项目分析完成,请确认迁移方式", + } + failed_status = { + "schema_version": 1, + "state": "failed", + "message": "项目分析未完成,请查看日志后重试", + "error": { + "code": "MIGRATION_ANALYSIS_FAILED", + "message": "Codex 未能完成只读项目分析。", + "retryable": False, + }, + } + start_failed_status = { + "schema_version": 1, + "state": "failed", + "message": "项目分析启动失败,请新建迁移后重试", + "error": { + "code": "MIGRATION_ANALYSIS_START_FAILED", + "message": "Codex 只读项目分析未能启动。", + "retryable": False, + }, + } + result_tmp = f"{_ANALYSIS_RESULT_PATH}.tmp" + log_path = f"{MIGRATION_ROOT}/logs/analysis.log" + pid_path = f"{MIGRATION_ROOT}/state/analysis.pid" + lock_path = f"{MIGRATION_ROOT}/state/analysis-start.lock" + validate_json = shlex.quote( + "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" + ) + inner = "\n".join( + [ + "set +e", + ( + "codex exec --sandbox read-only --skip-git-repo-check " + f"--cd {shlex.quote(_PROJECT_PATH)} " + f"--output-schema {shlex.quote(_ANALYSIS_SCHEMA_PATH)} " + f"--output-last-message {shlex.quote(result_tmp)} - " + f"< {shlex.quote(_ANALYSIS_PROMPT_PATH)} " + f"> {shlex.quote(log_path)} 2>&1" + ), + "code=$?", + ( + f'if [ "$code" -eq 0 ] && ' + f"python3 -c {validate_json} " + f"{shlex.quote(result_tmp)}; then" + ), + f" mv {shlex.quote(result_tmp)} {shlex.quote(_ANALYSIS_RESULT_PATH)}", + f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, ready_status)}", + "else", + f" rm -f {shlex.quote(result_tmp)}", + f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, failed_status)}", + "fi", + ( + f'printf \'%s\\n\' "{{\\"schema_version\\":1,' + f'\\"exit_code\\":$code}}" > ' + f"{shlex.quote(_ANALYSIS_PROCESS_EXIT_PATH)}.tmp" + ), + ( + f"mv {shlex.quote(_ANALYSIS_PROCESS_EXIT_PATH)}.tmp " + f"{shlex.quote(_ANALYSIS_PROCESS_EXIT_PATH)}" + ), + 'exit "$code"', + ] + ) + return "\n".join( + [ + "set -euo pipefail", + f"test -d {shlex.quote(_PROJECT_PATH)}", + f"if test -f {shlex.quote(_ANALYSIS_STATUS_PATH)}; then exit 0; fi", + "command -v bash >/dev/null", + "command -v codex >/dev/null", + "command -v setsid >/dev/null", + f"if ! mkdir {shlex.quote(lock_path)}; then", + (f" if test -f {shlex.quote(_ANALYSIS_STATUS_PATH)}; then exit 0; fi"), + ( + f" if test -s {shlex.quote(pid_path)} && " + f'kill -0 "$(cat {shlex.quote(pid_path)})" 2>/dev/null; ' + "then exit 0; fi" + ), + ' echo "analysis start lock exists without a live process" >&2', + " exit 1", + "fi", + "analysis_start_complete=0", + "cleanup_analysis_start() {", + " code=$?", + ' if [ "$analysis_start_complete" -ne 1 ]; then', + f" rm -f {shlex.quote(pid_path)} {shlex.quote(f'{pid_path}.tmp')}", + ( + f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, start_failed_status)} " + "|| true" + ), + f" rmdir {shlex.quote(lock_path)} 2>/dev/null || true", + " fi", + ' return "$code"', + "}", + "trap cleanup_analysis_start EXIT", + _atomic_json_command(_ANALYSIS_STATUS_PATH, running_status), + f"setsid bash -c {shlex.quote(inner)} /dev/null 2>&1 &", + "pid=$!", + f"printf '%s\\n' \"$pid\" > {shlex.quote(pid_path)}.tmp", + f"mv {shlex.quote(pid_path)}.tmp {shlex.quote(pid_path)}", + 'kill -0 "$pid"', + "analysis_start_complete=1", + "trap - EXIT", + ] + ) + + +def _migration_instruction( + request: dict[str, object], + confirmation: dict[str, object], + analysis: dict[str, object], +) -> str: + answers = confirmation.get("answers") + answer_lines = [] + if isinstance(answers, dict): + answer_lines = [ + f"- {key}: {value}" + for key, value in answers.items() + if isinstance(key, str) and isinstance(value, str) and value + ] + boundary = analysis.get("boundary") + boundary_text = json.dumps(boundary, ensure_ascii=False, indent=2) + return "\n".join( + [ + "# Confirmed migration requirements", + "", + str(request.get("instruction") or "No initial instruction."), + "", + str(confirmation.get("instruction") or "No additional instruction."), + "", + "## Confirmed answers", + "", + *(answer_lines or ["- No additional answers."]), + "", + "## Analysis boundary", + "", + boundary_text, + "", + "Preserve observable behavior and external integration boundaries.", + "Apply AgentKit best practices without claiming unverified fidelity.", + "Use the same language as the user's instructions in reports.", + "", + ] + ) + + +def _ak_command( + task_id: str, + confirmation: dict[str, object], +) -> str: + framework = str(confirmation["framework"]) + app_name = str(confirmation["app_name"]) + source = f"{MIGRATION_ROOT}/workspace/source" + common = [ + "ak", + "migrate", + source, + "--framework", + framework, + "--name", + app_name, + "--delivery-dir", + f"{MIGRATION_ROOT}/delivery", + "--provenance-file", + _CONFIRMATION_PATH, + "--run-id", + task_id, + ] + if framework in STRUCTURED_MIGRATION_FRAMEWORKS: + common.extend( + [ + "--entry", + str(confirmation["entry"]), + "--output", + "migrated", + "--verify", + ] + ) + else: + common.extend( + [ + "--execution", + "in-place", + "--output", + f"{MIGRATION_ROOT}/output/veadk", + "--work-dir", + f"{MIGRATION_ROOT}/work/agentic", + "--non-interactive", + "--instruction-file", + _INSTRUCTION_PATH, + ] + ) + return " ".join(shlex.quote(item) for item in common) + + +def _start_migration_command( + task_id: str, + confirmation: dict[str, object], + confirmation_sha256: str, + confirmation_candidate: str, + instruction_candidate: str, +) -> str: + pid_path = f"{MIGRATION_ROOT}/state/migration.pid" + log_path = f"{MIGRATION_ROOT}/logs/migration.log" + lock_path = f"{MIGRATION_ROOT}/state/migration-start.lock" + cli = _ak_command(task_id, confirmation) + inner = "\n".join( + [ + "set +e", + f"{cli} > {shlex.quote(log_path)} 2>&1", + "code=$?", + ( + f'printf \'%s\\n\' "{{\\"schema_version\\":1,' + f'\\"exit_code\\":$code}}" > {shlex.quote(_PROCESS_EXIT_PATH)}.tmp' + ), + ( + f"mv {shlex.quote(_PROCESS_EXIT_PATH)}.tmp " + f"{shlex.quote(_PROCESS_EXIT_PATH)}" + ), + 'exit "$code"', + ] + ) + return "\n".join( + [ + "set -euo pipefail", + ( + f"if test -f {shlex.quote(_CONFIRMATION_PATH)} || " + f"test -f {shlex.quote(_DELIVERY_STATUS_PATH)} || " + f"test -f {shlex.quote(_PROCESS_EXIT_PATH)}; then exit 0; fi" + ), + "command -v ak >/dev/null", + "command -v awk >/dev/null", + "command -v bash >/dev/null", + "command -v cp >/dev/null", + "command -v setsid >/dev/null", + "command -v sha256sum >/dev/null", + f"if ! mkdir {shlex.quote(lock_path)}; then", + ( + f" if test -f {shlex.quote(_CONFIRMATION_PATH)} || " + f"test -f {shlex.quote(_DELIVERY_STATUS_PATH)} || " + f"test -f {shlex.quote(_PROCESS_EXIT_PATH)}; then exit 0; fi" + ), + ( + f" if test -s {shlex.quote(pid_path)} && " + f'kill -0 "$(cat {shlex.quote(pid_path)})" 2>/dev/null; ' + "then exit 0; fi" + ), + ' echo "migration start lock exists without a live process" >&2', + " exit 1", + "fi", + "migration_start_complete=0", + "cleanup_migration_start() {", + " code=$?", + ' if [ "$migration_start_complete" -ne 1 ]; then', + f" rm -f {shlex.quote(pid_path)} {shlex.quote(f'{pid_path}.tmp')}", + ( + f" {_atomic_json_command(_PROCESS_EXIT_PATH, {'schema_version': 1, 'exit_code': 125})} " + "|| true" + ), + f" rmdir {shlex.quote(lock_path)} 2>/dev/null || true", + " fi", + ' return "$code"', + "}", + "trap cleanup_migration_start EXIT", + ( + f'test "$(sha256sum {shlex.quote(confirmation_candidate)} ' + f"| awk '{{print $1}}')\" = {shlex.quote(confirmation_sha256)}" + ), + ( + f"mv {shlex.quote(confirmation_candidate)} " + f"{shlex.quote(_CONFIRMATION_PATH)}" + ), + ( + f"mv {shlex.quote(instruction_candidate)} " + f"{shlex.quote(_INSTRUCTION_PATH)}" + ), + f"test -d {shlex.quote(_PROJECT_PATH)}", + f"mkdir -p {shlex.quote(f'{MIGRATION_ROOT}/workspace')}", + f"test ! -e {shlex.quote(f'{MIGRATION_ROOT}/workspace/source')}", + ( + f"cp -a {shlex.quote(_PROJECT_PATH)} " + f"{shlex.quote(f'{MIGRATION_ROOT}/workspace/source')}" + ), + f"setsid bash -c {shlex.quote(inner)} /dev/null 2>&1 &", + "pid=$!", + f"printf '%s\\n' \"$pid\" > {shlex.quote(pid_path)}.tmp", + f"mv {shlex.quote(pid_path)}.tmp {shlex.quote(pid_path)}", + 'kill -0 "$pid"', + "migration_start_complete=1", + "trap - EXIT", + ] + ) + + +def _stop_command() -> str: + status = { + "schema_version": 1, + "state": "cancelled", + "message": "迁移已终止", + } + python = f""" +import os +import signal +import time +from pathlib import Path + +root = Path({MIGRATION_ROOT!r}) +root_marker = str(root).encode() +for name in ("analysis.pid", "migration.pid"): + path = root / "state" / name + if not path.exists(): + continue + try: + pid = int(path.read_text(encoding="ascii").strip()) + command = Path(f"/proc/{{pid}}/cmdline").read_bytes().replace(b"\\0", b" ") + if root_marker not in command or ( + b"codex exec" not in command and b"ak migrate" not in command + ): + raise RuntimeError("pid does not belong to this migration") + process_group = os.getpgid(pid) + os.killpg(process_group, signal.SIGTERM) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + else: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + finally: + path.unlink(missing_ok=True) +""" + return "\n".join( + [ + "set -euo pipefail", + "python3 - <<'PY'", + python.strip(), + "PY", + _atomic_json_command(_STOPPED_PATH, status), + ] + ) + + +class MigrationService: + """Derive task state from remote Sessions and files without a local repository.""" + + def __init__( + self, + gateway: MigrationGateway, + *, + clock: Callable[[], float] = time.time, + ) -> None: + self._gateway = gateway + self._clock = clock + + @staticmethod + def _translate(error: MigrationGatewayError) -> MigrationError: + return MigrationError( + error.code, + str(error), + status_code=error.status_code, + retryable=error.retryable, + ) + + def capabilities(self) -> dict[str, object]: + capability = self._gateway.capabilities() + return { + "enabled": bool(capability.get("enabled")), + "reason": str(capability.get("reason") or ""), + "maxUploadBytes": MIGRATION_UPLOAD_MAX_BYTES, + "sessionTtlSeconds": MIGRATION_SESSION_TTL_SECONDS, + "frameworks": list(MIGRATION_FRAMEWORKS), + } + + @staticmethod + def _validate_task_id(task_id: str) -> None: + if not _TASK_ID_RE.fullmatch(task_id): + raise MigrationError( + "MIGRATION_TASK_NOT_FOUND", + "迁移会话不存在或已过期。", + status_code=404, + ) + + def _session(self, task_id: str, owner_id: str) -> MigrationSandboxSession: + self._validate_task_id(task_id) + try: + return self._gateway.find_session(task_id, owner_id) + except MigrationGatewayError as error: + raise self._translate(error) from error + + def _put( + self, + session: MigrationSandboxSession, + path: str, + content: bytes, + *, + media_type: str, + ) -> None: + try: + self._gateway.put_file( + session, + path, + content, + media_type=media_type, + ) + except MigrationGatewayError as error: + raise self._translate(error) from error + + def _execute( + self, + session: MigrationSandboxSession, + command: str, + *, + operation: str, + timeout_seconds: int = 120, + ) -> dict[str, object]: + try: + return self._gateway.execute_bash( + session, + command, + operation=operation, + timeout_seconds=timeout_seconds, + ) + except MigrationGatewayError as error: + raise self._translate(error) from error + + def _read( + self, + session: MigrationSandboxSession, + path: str, + *, + max_bytes: int = _MAX_JSON_BYTES, + optional: bool = False, + ) -> bytes | None: + try: + return self._gateway.get_file( + session, + path, + max_bytes=max_bytes, + ) + except MigrationRemoteFileNotFound as error: + if optional: + return None + raise self._translate(error) from error + except MigrationGatewayError as error: + raise self._translate(error) from error + + def _read_json( + self, + session: MigrationSandboxSession, + path: str, + *, + optional: bool = False, + ) -> dict[str, object] | None: + content = self._read(session, path, optional=optional) + if content is None: + return None + try: + value = json.loads(content) + except (UnicodeDecodeError, ValueError) as error: + raise MigrationError( + "MIGRATION_REMOTE_STATE_INVALID", + "迁移会话状态文件格式无效。", + status_code=502, + ) from error + if not isinstance(value, dict): + raise MigrationError( + "MIGRATION_REMOTE_STATE_INVALID", + "迁移会话状态文件格式无效。", + status_code=502, + ) + return {str(key): item for key, item in value.items()} + + def create_task( + self, + body: CreateMigrationTaskBody, + owner_id: str, + creator_name: str, + ) -> dict[str, object]: + capability = self.capabilities() + if not capability["enabled"]: + raise MigrationError( + "MIGRATION_DEVENV_UNAVAILABLE", + str(capability["reason"]) or "Dev Sandbox 暂不可用。", + status_code=503, + ) + task_id = body.task_id or f"migration-v1-{uuid.uuid4().hex}" + request = { + "schema_version": 1, + "task_id": task_id, + "source_file_name": body.source_file_name, + "instruction": body.instruction, + "session_ttl_seconds": MIGRATION_SESSION_TTL_SECONDS, + } + try: + session = self._gateway.create_session( + task_id=task_id, + owner_id=owner_id, + creator_name=creator_name, + display_name="存量迁移", + ttl_seconds=MIGRATION_SESSION_TTL_SECONDS, + ) + existing_request = self._read_json( + session, + _REQUEST_PATH, + optional=True, + ) + if existing_request is not None: + self._validate_request(existing_request, request) + return self._task_from_session(session) + request["created_at"] = session.created_at or int(self._clock()) + request_content = _json_bytes(request) + request_sha256 = hashlib.sha256(request_content).hexdigest() + request_candidate = f"{MIGRATION_ROOT}/state/.request-{request_sha256}.json" + self._put( + session, + request_candidate, + request_content, + media_type="application/json", + ) + self._execute( + session, + _accept_request_command(request_candidate, request_sha256), + operation="accept_request", + timeout_seconds=30, + ) + accepted_request = self._read_json(session, _REQUEST_PATH) + if accepted_request is None: + raise MigrationError( + "MIGRATION_REQUEST_MISSING", + "迁移请求文件不存在。", + status_code=502, + ) + self._validate_request(accepted_request, request) + except MigrationGatewayError as error: + raise self._translate(error) from error + return self._task_payload(session, request) + + @staticmethod + def _validated_request( + value: object, + task_id: str, + ) -> dict[str, object]: + try: + return validate_migration_request( + value, + expected_task_id=task_id, + expected_ttl_seconds=MIGRATION_SESSION_TTL_SECONDS, + ) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_REQUEST_INVALID", + "迁移请求文件与当前 Session 不匹配或格式无效。", + status_code=502, + ) from error + + @staticmethod + def _validated_source(value: object) -> dict[str, object]: + try: + return validate_source_status(value) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_SOURCE_STATE_INVALID", + "上传项目的来源状态无效。", + status_code=502, + ) from error + + @staticmethod + def _validated_confirmation( + value: object, + task_id: str, + ) -> dict[str, object]: + try: + return validate_confirmation(value, expected_task_id=task_id) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_CONFIRMATION_INVALID", + "迁移确认状态无效。", + status_code=502, + ) from error + + @staticmethod + def _validated_process_exit( + value: object, + *, + analysis: bool = False, + ) -> dict[str, object]: + try: + return validate_process_exit(value) + except MigrationContractError as error: + raise MigrationError( + ( + "MIGRATION_ANALYSIS_PROCESS_STATE_INVALID" + if analysis + else "MIGRATION_PROCESS_STATE_INVALID" + ), + ( + "Codex 分析进程状态无效。" + if analysis + else "AgentKit CLI 进程状态无效。" + ), + status_code=502, + ) from error + + @staticmethod + def _validate_request( + existing: dict[str, object], + expected: dict[str, object], + ) -> None: + MigrationService._validated_request( + existing, + str(expected["task_id"]), + ) + if ( + existing.get("source_file_name") != expected["source_file_name"] + or existing.get("instruction") != expected["instruction"] + or existing.get("session_ttl_seconds") != expected["session_ttl_seconds"] + ): + raise MigrationError( + "MIGRATION_REQUEST_CONFLICT", + "该迁移会话 ID 已用于其他迁移请求。", + status_code=409, + retryable=False, + ) + + def upload_source( + self, + task_id: str, + owner_id: str, + content: bytes, + ) -> dict[str, object]: + summary = validate_source_archive(content) + session = self._session(task_id, owner_id) + current = self.get_task(task_id, owner_id) + if current["state"] != "awaiting_upload": + raise MigrationError( + "MIGRATION_SOURCE_LOCKED", + "分析开始后不能修改项目附件;请等待完成或终止当前迁移。", + status_code=409, + ) + digest = hashlib.sha256(content).hexdigest() + accepted_source = self._read_json( + session, + _SOURCE_STATUS_PATH, + optional=True, + ) + if accepted_source is not None: + accepted_source = self._validated_source(accepted_source) + accepted_digest = accepted_source.get("sha256") + if accepted_digest != digest: + raise MigrationError( + "MIGRATION_SOURCE_LOCKED", + "项目附件已锁定;只能使用原 ZIP 继续启动分析。", + status_code=409, + ) + else: + candidate = f"{MIGRATION_ROOT}/input/.source-{digest}.zip" + self._put( + session, + candidate, + content, + media_type="application/zip", + ) + self._execute( + session, + _prepare_source_command( + candidate_path=candidate, + source_sha256=digest, + source_size=len(content), + summary=summary, + ), + operation="prepare_source", + timeout_seconds=_FILE_OPERATION_TIMEOUT_SECONDS, + ) + request = self._read_json(session, _REQUEST_PATH) + if request is None: + raise MigrationError( + "MIGRATION_REQUEST_MISSING", + "迁移请求文件不存在。", + status_code=502, + ) + request = self._validated_request(request, task_id) + self._put( + session, + _ANALYSIS_SCHEMA_PATH, + _json_bytes(_analysis_schema()), + media_type="application/json", + ) + self._put( + session, + _ANALYSIS_PROMPT_PATH, + _analysis_prompt(request).encode("utf-8"), + media_type="text/markdown", + ) + self._execute( + session, + _start_analysis_command(), + operation="start_analysis", + timeout_seconds=30, + ) + return self.get_task(task_id, owner_id) + + def list_tasks(self, owner_id: str) -> dict[str, list[dict[str, object]]]: + try: + sessions = self._gateway.list_sessions(owner_id) + except MigrationGatewayError as error: + raise self._translate(error) from error + tasks = [] + for session in sessions: + try: + tasks.append(self._task_from_session(session)) + except MigrationError as error: + logger.warning( + "Ignoring invalid state for one migration Session " + "task_id=%s code=%s retryable=%s", + session.task_id, + error.code, + str(error.retryable).lower(), + ) + tasks.append( + self._task_payload( + session, + None, + state="failed", + message=( + "暂时无法读取该迁移会话,请稍后刷新。" + if error.retryable + else "该迁移会话初始化或状态文件不完整,请新建迁移。" + ), + error=error.detail(), + ) + ) + return {"items": tasks} + + def get_task(self, task_id: str, owner_id: str) -> dict[str, object]: + return self._task_from_session(self._session(task_id, owner_id)) + + @staticmethod + def _artifact_status(value: object = None) -> dict[str, object]: + data = value if isinstance(value, dict) else {} + return { + "state": str(data.get("state") or "none"), + "previewReady": bool(data.get("preview_ready")), + "downloadReady": bool(data.get("download_ready")), + "deployReady": bool(data.get("deploy_ready")), + } + + def _task_payload( + self, + session: MigrationSandboxSession, + request: dict[str, object] | None, + *, + state: str = "awaiting_upload", + message: str = "请上传本地项目 ZIP", + artifact: object = None, + analysis: dict[str, object] | None = None, + confirmation: dict[str, object] | None = None, + error: object = None, + ) -> dict[str, object]: + request = request or {} + expiry = self._session_expiry(session, request) + payload: dict[str, object] = { + "id": session.task_id, + "state": state, + "message": message, + "sourceFileName": str(request.get("source_file_name") or "项目 ZIP"), + "instruction": str(request.get("instruction") or ""), + "createdAt": session.created_at or request.get("created_at") or "", + "expiresAt": _iso_timestamp(expiry) if expiry is not None else "", + "sessionTtlSeconds": MIGRATION_SESSION_TTL_SECONDS, + "canModify": state in {"awaiting_upload", "analysis_ready"}, + "canUpload": state == "awaiting_upload", + "canConfirm": state == "analysis_ready", + "canStop": state in _ACTIVE_STATES, + "artifact": self._artifact_status(artifact), + } + if analysis is not None: + payload["analysis"] = analysis + if confirmation is not None: + payload["confirmation"] = confirmation + if isinstance(error, dict): + payload["error"] = error + return payload + + @staticmethod + def _session_expiry( + session: MigrationSandboxSession, + request: dict[str, object] | None = None, + ) -> float | None: + explicit = _timestamp(session.expire_at) + if explicit is not None: + candidates = [explicit] + else: + candidates = [] + created = _timestamp(session.created_at) + if created is not None: + candidates.append(created + MIGRATION_SESSION_TTL_SECONDS) + request_created = _timestamp((request or {}).get("created_at")) + if request_created is not None: + candidates.append(request_created + MIGRATION_SESSION_TTL_SECONDS) + return min(candidates) if candidates else None + + def _session_expired( + self, + session: MigrationSandboxSession, + request: dict[str, object] | None = None, + ) -> bool: + expiry = self._session_expiry(session, request) + return expiry is not None and self._clock() >= expiry + + def _task_from_session( + self, + session: MigrationSandboxSession, + ) -> dict[str, object]: + if session.released or not session.endpoint or self._session_expired(session): + return self._task_payload( + session, + None, + state="expired", + message="Dev Sandbox 已超过 1 小时 TTL,迁移内容和产物不可再访问。", + ) + request = self._read_json(session, _REQUEST_PATH) + if request is None: + raise MigrationError( + "MIGRATION_REQUEST_INVALID", + "迁移请求文件不存在。", + status_code=502, + ) + request = self._validated_request(request, session.task_id) + if self._session_expired(session, request): + return self._task_payload( + session, + request, + state="expired", + message="Dev Sandbox 已超过 1 小时 TTL,迁移内容和产物不可再访问。", + ) + stopped = self._read_json(session, _STOPPED_PATH, optional=True) + if stopped is not None: + try: + stopped = validate_stopped_status(stopped) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_STOP_STATE_INVALID", + "迁移终止状态无效。", + status_code=502, + ) from error + return self._task_payload( + session, + request, + state="cancelled", + message=str(stopped.get("message") or "迁移已终止"), + ) + confirmation = self._read_json(session, _CONFIRMATION_PATH, optional=True) + if confirmation is not None: + confirmation = self._validated_confirmation( + confirmation, + session.task_id, + ) + delivery = self._read_json(session, _DELIVERY_STATUS_PATH, optional=True) + if delivery is not None: + try: + delivery = validate_delivery_status( + delivery, + expected_run_id=session.task_id, + ) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_DELIVERY_INVALID", + "迁移交付状态无效。", + status_code=502, + ) from error + state = str(delivery["state"]) + return self._task_payload( + session, + request, + state=state, + message=str(delivery.get("message") or "正在迁移项目"), + artifact=delivery.get("artifact"), + confirmation=confirmation, + error=delivery.get("error"), + ) + process_exit = self._read_json(session, _PROCESS_EXIT_PATH, optional=True) + if process_exit is not None: + process_exit = self._validated_process_exit(process_exit) + exit_code = process_exit["exit_code"] + if exit_code != 0: + return self._task_payload( + session, + request, + state="failed", + message="迁移命令未成功完成,请查看日志。", + confirmation=confirmation, + error={ + "code": "MIGRATION_PROCESS_FAILED", + "message": "AgentKit CLI 迁移命令执行失败。", + "retryable": False, + }, + ) + return self._task_payload( + session, + request, + state="failed", + message="迁移命令已结束,但没有生成交付状态。", + confirmation=confirmation, + error={ + "code": "MIGRATION_DELIVERY_MISSING", + "message": "AgentKit CLI 未生成完整的迁移交付状态。", + "retryable": False, + }, + ) + if confirmation is not None: + return self._task_payload( + session, + request, + state="migrating", + message="正在启动 AgentKit CLI 迁移", + confirmation=confirmation, + ) + analysis_status = self._read_json( + session, + _ANALYSIS_STATUS_PATH, + optional=True, + ) + if analysis_status is not None: + try: + analysis_status = validate_analysis_status(analysis_status) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_ANALYSIS_STATE_INVALID", + "Codex 分析状态无效。", + status_code=502, + ) from error + analysis_state = str(analysis_status.get("state") or "") + if analysis_state == "ready": + analysis = self._read_json(session, _ANALYSIS_RESULT_PATH) + try: + analysis = validate_analysis_result(analysis) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_ANALYSIS_INVALID", + "Codex 分析结果格式无效。", + status_code=502, + ) from error + return self._task_payload( + session, + request, + state="analysis_ready", + message=str(analysis_status.get("message") or "请确认迁移方式"), + analysis=analysis, + ) + if analysis_state == "failed": + return self._task_payload( + session, + request, + state="failed", + message=str(analysis_status.get("message") or "项目分析未完成"), + error=analysis_status.get("error"), + ) + if analysis_state == "analyzing": + analysis_exit = self._read_json( + session, + _ANALYSIS_PROCESS_EXIT_PATH, + optional=True, + ) + if analysis_exit is not None: + analysis_exit = self._validated_process_exit( + analysis_exit, + analysis=True, + ) + exit_code = analysis_exit["exit_code"] + result_missing = exit_code == 0 + return self._task_payload( + session, + request, + state="failed", + message=( + "项目分析已结束,但没有生成分析结果。" + if result_missing + else "项目分析未成功完成,请查看日志。" + ), + error={ + "code": ( + "MIGRATION_ANALYSIS_RESULT_MISSING" + if result_missing + else "MIGRATION_ANALYSIS_FAILED" + ), + "message": ( + "Codex 未生成完整的项目分析结果。" + if result_missing + else "Codex 只读项目分析执行失败。" + ), + "retryable": False, + }, + ) + return self._task_payload( + session, + request, + state="analyzing", + message=str(analysis_status.get("message") or "正在分析项目"), + ) + raise MigrationError( + "MIGRATION_ANALYSIS_STATE_INVALID", + "Codex 分析状态无效。", + status_code=502, + ) + source = self._read_json(session, _SOURCE_STATUS_PATH, optional=True) + if source is not None: + self._validated_source(source) + return self._task_payload( + session, + request, + state="awaiting_upload", + message="项目已上传,请重新选择同一 ZIP 继续启动分析。", + ) + return self._task_payload(session, request) + + def confirm( + self, + task_id: str, + owner_id: str, + body: ConfirmMigrationBody, + ) -> dict[str, object]: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + if task["state"] != "analysis_ready": + raise MigrationError( + "MIGRATION_DECISION_LOCKED", + ( + "迁移执行中不能修改迁移方式;请等待完成或终止当前迁移。" + if task["state"] in _ACTIVE_STATES + else "请等待项目分析完成后再确认迁移方式。" + ), + status_code=409, + ) + request = self._read_json(session, _REQUEST_PATH) + analysis = self._read_json(session, _ANALYSIS_RESULT_PATH) + if request is None or analysis is None: + raise MigrationError( + "MIGRATION_ANALYSIS_MISSING", + "项目分析结果不存在。", + status_code=502, + ) + request = self._validated_request(request, task_id) + try: + analysis = validate_analysis_result(analysis) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_ANALYSIS_INVALID", + "Codex 分析结果格式无效。", + status_code=502, + ) from error + source = self._read_json(session, _SOURCE_STATUS_PATH) + if source is None: + raise MigrationError( + "MIGRATION_SOURCE_STATE_INVALID", + "上传项目的来源状态无效。", + status_code=502, + ) + source = self._validated_source(source) + source_sha256 = str(source["sha256"]) + confirmation = { + "schema_version": 1, + "task_id": task_id, + "source_archive_sha256": source_sha256, + "framework": body.framework, + "entry": body.entry, + "app_name": body.app_name, + "instruction": body.instruction, + "answers": body.answers, + "confirmed_at": int(self._clock()), + } + confirmation_content = _json_bytes(confirmation) + confirmation_sha = hashlib.sha256(confirmation_content).hexdigest() + confirmation_candidate = ( + f"{MIGRATION_ROOT}/state/.confirmation-{confirmation_sha}.json" + ) + instruction_content = _migration_instruction( + request, + confirmation, + analysis, + ).encode("utf-8") + instruction_sha = hashlib.sha256(instruction_content).hexdigest() + instruction_candidate = ( + f"{MIGRATION_ROOT}/state/.instructions-{instruction_sha}.md" + ) + self._put( + session, + confirmation_candidate, + confirmation_content, + media_type="application/json", + ) + self._put( + session, + instruction_candidate, + instruction_content, + media_type="text/markdown", + ) + self._execute( + session, + _start_migration_command( + task_id, + confirmation, + confirmation_sha, + confirmation_candidate, + instruction_candidate, + ), + operation="start_migration", + timeout_seconds=_FILE_OPERATION_TIMEOUT_SECONDS, + ) + return self.get_task(task_id, owner_id) + + def stop(self, task_id: str, owner_id: str) -> dict[str, object]: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + if task["state"] == "expired": + raise MigrationError( + "MIGRATION_SESSION_EXPIRED", + "Dev Sandbox 已清理,无法再终止任务。", + status_code=410, + retryable=False, + ) + if task["state"] not in _ACTIVE_STATES: + raise MigrationError( + "MIGRATION_NOT_RUNNING", + "当前迁移不处于可终止状态。", + status_code=409, + ) + self._execute( + session, + _stop_command(), + operation="stop", + timeout_seconds=30, + ) + return self.get_task(task_id, owner_id) + + def artifact(self, task_id: str, owner_id: str) -> dict[str, object]: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + return self._artifact_result(session, task, readiness="previewReady") + + def _artifact_result( + self, + session: MigrationSandboxSession, + task: dict[str, object], + *, + readiness: str, + ) -> dict[str, object]: + artifact = task.get("artifact") + if ( + not isinstance(artifact, dict) + or not artifact.get(readiness) + or task["state"] not in {"succeeded", "succeeded_with_warnings", "partial"} + ): + raise MigrationError( + "MIGRATION_ARTIFACT_NOT_READY", + "迁移产物尚未准备完成。", + status_code=409, + ) + result = self._read_json(session, _DELIVERY_RESULT_PATH) + if result is None: + raise MigrationError( + "MIGRATION_ARTIFACT_MISSING", + "迁移产物清单不存在。", + status_code=502, + ) + confirmation_content = self._read( + session, + _CONFIRMATION_PATH, + max_bytes=_MAX_PROVENANCE_BYTES, + ) + if confirmation_content is None: + raise MigrationError( + "MIGRATION_CONFIRMATION_MISSING", + "迁移确认文件不存在。", + status_code=502, + ) + source = self._read_json(session, _SOURCE_STATUS_PATH) + if source is None: + raise MigrationError( + "MIGRATION_SOURCE_STATE_INVALID", + "上传项目的来源状态无效。", + status_code=502, + ) + source = self._validated_source(source) + source_sha256 = str(source["sha256"]) + try: + result = validate_delivery_result( + result, + expected_run_id=session.task_id, + expected_status=str(task["state"]), + ) + except MigrationContractError as error: + raise MigrationError( + "MIGRATION_ARTIFACT_INVALID", + "AgentKit CLI 产物清单格式无效。", + status_code=502, + ) from error + self._validate_result_binding( + result, + expected_provenance_sha256=hashlib.sha256(confirmation_content).hexdigest(), + expected_source_archive_sha256=source_sha256, + confirmation=task.get("confirmation"), + ) + return result + + @staticmethod + def _validate_result_binding( + result: dict[str, object], + *, + expected_provenance_sha256: str, + expected_source_archive_sha256: str, + confirmation: object, + ) -> None: + migration = result.get("migration") + assert isinstance(migration, dict) + if migration.get("provenance_sha256") != expected_provenance_sha256: + raise MigrationError( + "MIGRATION_ARTIFACT_PROVENANCE_MISMATCH", + "AgentKit CLI 产物与当前迁移确认不匹配。", + status_code=502, + ) + if not isinstance(confirmation, dict): + raise MigrationError( + "MIGRATION_CONFIRMATION_INVALID", + "迁移确认状态无效。", + status_code=502, + ) + if confirmation.get("source_archive_sha256") != expected_source_archive_sha256: + raise MigrationError( + "MIGRATION_ARTIFACT_SOURCE_MISMATCH", + "AgentKit CLI 产物与当前上传项目不匹配。", + status_code=502, + ) + framework = confirmation.get("framework") + expected_engine = ( + "structured" if framework in STRUCTURED_MIGRATION_FRAMEWORKS else "agentic" + ) + if ( + migration.get("framework") != framework + or migration.get("engine") != expected_engine + or ( + expected_engine == "structured" + and migration.get("entry") != confirmation.get("entry") + ) + ): + raise MigrationError( + "MIGRATION_ARTIFACT_DECISION_MISMATCH", + "AgentKit CLI 产物与已确认的迁移方式不匹配。", + status_code=502, + ) + + def preview_file( + self, + task_id: str, + owner_id: str, + path: str, + ) -> tuple[bytes, str]: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + result = self._artifact_result(session, task, readiness="previewReady") + normalized = PurePosixPath(path).as_posix() + files = result.get("files") + if not isinstance(files, list): + raise MigrationError( + "MIGRATION_ARTIFACT_INVALID", + "AgentKit CLI 产物文件清单格式无效。", + status_code=502, + ) + descriptor = next( + ( + item + for item in files + if isinstance(item, dict) and item.get("path") == normalized + ), + None, + ) + if descriptor is None: + raise MigrationError( + "MIGRATION_ARTIFACT_FILE_NOT_FOUND", + "迁移产物中不存在该文件。", + status_code=404, + ) + size = descriptor["size"] + if not isinstance(size, int) or size > _MAX_PREVIEW_BYTES: + raise MigrationError( + "MIGRATION_ARTIFACT_FILE_TOO_LARGE", + "该文件超过 2 MiB,无法在线预览,请下载产物后查看。", + status_code=413, + ) + migration = result["migration"] + assert isinstance(migration, dict) + project_root = ( + f"{MIGRATION_ROOT}/workspace/source" + if migration.get("engine") == "structured" + else f"{MIGRATION_ROOT}/output/veadk" + ) + content = self._read( + session, + f"{project_root}/{normalized}", + max_bytes=_MAX_PREVIEW_BYTES, + ) + if content is None: + raise MigrationError( + "MIGRATION_ARTIFACT_FILE_NOT_FOUND", + "迁移产物文件不存在。", + status_code=404, + ) + if ( + len(content) != size + or hashlib.sha256(content).hexdigest() != descriptor["sha256"] + ): + raise MigrationError( + "MIGRATION_ARTIFACT_INTEGRITY_FAILED", + "迁移产物文件完整性校验失败。", + status_code=502, + ) + media_type = mimetypes.guess_type(normalized)[0] or "application/octet-stream" + return content, media_type + + def download( + self, + task_id: str, + owner_id: str, + ) -> tuple[bytes, str]: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + result = self._artifact_result(session, task, readiness="downloadReady") + content = self._verified_artifact_content(session, result) + request = self._read_json(session, _REQUEST_PATH) + if request is None: + raise MigrationError( + "MIGRATION_REQUEST_INVALID", + "迁移请求文件不存在。", + status_code=502, + ) + request = self._validated_request(request, task_id) + source_name = str(request.get("source_file_name") or "project.zip") + stem = source_name[:-4] if source_name.lower().endswith(".zip") else source_name + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-") or "project" + return content, f"{safe_stem}-migrated.zip" + + def _verified_artifact_content( + self, + session: MigrationSandboxSession, + result: dict[str, object], + ) -> bytes: + descriptor = result["artifact"] + assert isinstance(descriptor, dict) + content = self._read( + session, + _DELIVERY_ARTIFACT_PATH, + max_bytes=_MAX_ARTIFACT_BYTES, + ) + if content is None: + raise MigrationError( + "MIGRATION_ARTIFACT_MISSING", + "迁移产物不存在。", + status_code=502, + ) + if ( + len(content) != descriptor["size"] + or hashlib.sha256(content).hexdigest() != descriptor["sha256"] + ): + raise MigrationError( + "MIGRATION_ARTIFACT_INTEGRITY_FAILED", + "迁移产物完整性校验失败。", + status_code=502, + ) + return content + + def materialize_deployment( + self, + task_id: str, + owner_id: str, + target: Path, + ) -> str: + session = self._session(task_id, owner_id) + task = self._task_from_session(session) + artifact_status = task.get("artifact") + if ( + task.get("state") not in {"succeeded", "succeeded_with_warnings"} + or not isinstance(artifact_status, dict) + or not artifact_status.get("deployReady") + ): + raise MigrationError( + "MIGRATION_ARTIFACT_NOT_DEPLOYABLE", + "迁移产物未通过部署校验,无法部署到 Runtime。", + status_code=409, + ) + result = self._artifact_result(session, task, readiness="deployReady") + content = self._verified_artifact_content(session, result) + try: + return extract_migration_source(target, content, result) + except DeploymentSourceError as error: + raise MigrationError( + "MIGRATION_ARTIFACT_INTEGRITY_FAILED", + str(error), + status_code=502, + retryable=False, + ) from error + + def delete(self, task_id: str, owner_id: str) -> None: + session = self._session(task_id, owner_id) + try: + self._gateway.delete_session(session) + except MigrationGatewayError as error: + raise self._translate(error) from error + + +__all__ = [ + "MIGRATION_ROOT", + "MIGRATION_SESSION_TTL_SECONDS", + "MIGRATION_UPLOAD_MAX_BYTES", + "MigrationError", + "MigrationService", + "SourceArchiveSummary", + "validate_source_archive", +] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9e04e26..2c595c5e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -117,6 +117,7 @@ import { CustomCreate } from "./create/CustomCreate"; import { TemplateCreate } from "./create/TemplateCreate"; import { WorkflowCreate } from "./create/WorkflowCreate"; import { CodePackageCreate } from "./create/CodePackageCreate"; +import { MigrationWorkspace } from "./migrations/MigrationWorkspace"; import type { AgentDraft } from "./create/types"; import { loadWorkspaceDrafts, @@ -245,7 +246,7 @@ async function probeNewChatCapabilities( }; } -type CreateMode = QuickCreateKind | "package"; +type CreateMode = QuickCreateKind | "package" | "migration"; type CreateView = "menu" | CreateMode | null; type CustomCreateMode = "custom" | "yaml_import"; @@ -309,7 +310,7 @@ function mentionableDescendants(node: AgentNode): AgentTarget[] { function loadView(): CreateView { const v = typeof localStorage !== "undefined" ? localStorage.getItem(LS.view) : null; - return v === "menu" || v === "intelligent" || v === "custom" || v === "template" || v === "workflow" + return v === "menu" || v === "intelligent" || v === "custom" || v === "template" || v === "workflow" || v === "package" || v === "migration" ? v : null; } @@ -5158,9 +5159,11 @@ export default function App() { icon: MigrationIcon, title: "从存量迁移", desc: "从您的 LangChain / Dify 等存量项目迁移至 AgentKit Runtime", - status: "敬请期待", - disabled: true, - onClick: () => undefined, + onClick: () => { + setAddMenu(false); + setImportedDraft(null); + setCreateView("migration"); + }, }, ]} /> @@ -5323,6 +5326,19 @@ export default function App() { onDeploymentComplete={finishDeployment} initialDeployRegion={newRuntimeRegion} /> + ) : visibleCreateView === "migration" ? ( + { + setCreateView(null); + setAddMenu(true); + }} + onAgentAdded={onAgentAdded} + onDeploymentTaskChange={updateDeploymentTask} + onDeploymentStarted={startDeployment} + onDeploymentComplete={finishDeployment} + initialDeployRegion={newRuntimeRegion} + /> ) : turns.length === 0 && !newChatCapabilitiesReady ? (
正在检查 Agent 能力… diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index fac0c24e..ee80047d 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2114,6 +2114,7 @@ export async function deployAgentkitProject( }, opts?: { taskId?: string; + migrationTaskId?: string; runtimeId?: string; appName?: string; sessionStorage?: "in-memory" | "persistent"; @@ -2143,10 +2144,11 @@ export async function deployAgentkitProject( let res: Response; try { + const migrationSource = Boolean(opts?.migrationTaskId); opts?.onStage?.({ level: "info", phase: "upload", - message: "正在上传代码包", + message: migrationSource ? "正在校验迁移产物" : "正在上传代码包", pct: 0, }); res = await apiFetch( @@ -2157,9 +2159,10 @@ export async function deployAgentkitProject( signal: controller?.signal, body: JSON.stringify({ name, - files, + files: migrationSource ? [] : files, config, taskId, + migrationTaskId: opts?.migrationTaskId, runtimeId: opts?.runtimeId, appName: opts?.appName, sessionStorage: opts?.sessionStorage, @@ -2179,7 +2182,7 @@ export async function deployAgentkitProject( opts?.onStage?.({ level: "success", phase: "upload", - message: "代码包上传完成", + message: migrationSource ? "迁移产物校验完成" : "代码包上传完成", pct: 100, }); } catch (error) { diff --git a/frontend/src/adk/migrations.ts b/frontend/src/adk/migrations.ts new file mode 100644 index 00000000..a97aeefc --- /dev/null +++ b/frontend/src/adk/migrations.ts @@ -0,0 +1,799 @@ +import { withAuth } from "./auth"; +import { withLocalUser } from "./identity"; +import { + DEFAULT_REQUEST_TIMEOUT_MS, + requestSignal, + TRANSFER_REQUEST_TIMEOUT_MS, +} from "./timeout"; + +const API_ROOT = "/web/migrations"; +const SESSION_START_TIMEOUT_MS = 390_000; + +export type MigrationFramework = + | "langchain" + | "langgraph" + | "adk" + | "strands" + | "agentcore" + | "dify" + | "any"; + +export type MigrationTaskState = + | "awaiting_upload" + | "analyzing" + | "analysis_ready" + | "migrating" + | "validating" + | "packaging" + | "succeeded" + | "succeeded_with_warnings" + | "partial" + | "failed" + | "cancelled" + | "expired"; + +export interface MigrationCapabilities { + enabled: boolean; + reason: string; + maxUploadBytes: number; + sessionTtlSeconds: number; + frameworks: MigrationFramework[]; +} + +export interface MigrationEvidence { + path: string; + line: number; + reason: string; +} + +export interface MigrationAnalysis { + schema_version: 1; + summary: string; + frameworks: Array<{ + id: MigrationFramework; + confidence: "high" | "medium" | "low"; + evidence: MigrationEvidence[]; + }>; + recommended: { + framework: MigrationFramework; + entry: string | null; + reason: string; + }; + entries: Array<{ + value: string; + framework: MigrationFramework; + evidence: string; + }>; + boundary: { + include: string[]; + exclude: string[]; + }; + questions: Array<{ + id: string; + prompt: string; + required: boolean; + }>; + warnings: string[]; +} + +export interface MigrationTask { + id: string; + state: MigrationTaskState; + message: string; + sourceFileName: string; + instruction: string; + createdAt: string | number; + expiresAt: string; + sessionTtlSeconds: number; + canModify: boolean; + canUpload: boolean; + canConfirm: boolean; + canStop: boolean; + artifact: { + state: string; + previewReady: boolean; + downloadReady: boolean; + deployReady: boolean; + }; + analysis?: MigrationAnalysis; + confirmation?: { + framework?: MigrationFramework; + entry?: string | null; + app_name?: string; + }; + error?: { + code: string; + message: string; + retryable: boolean; + }; +} + +export interface MigrationArtifact { + schema_version: 1; + run_id?: string; + cli: { + name: string; + version: string; + }; + migration: { + engine: "structured" | "agentic"; + framework: string; + entry?: string; + source_sha256?: string; + provenance_sha256?: string; + }; + status: "succeeded" | "succeeded_with_warnings" | "partial"; + files: Array<{ + path: string; + size: number; + sha256: string; + mode: string; + }>; + startup: { + module: string; + object: string; + command?: string[]; + }; + environment: { + required: string[]; + optional: string[]; + }; + verification: { + status: "passed" | "failed" | "degraded"; + checks: Array<{ + name: string; + status: "passed" | "failed"; + detail?: string; + }>; + }; + warnings: string[]; + report: { + path: string; + }; + artifact: { + path: "migration-result.zip"; + size: number; + sha256: string; + }; + created_at: string; +} + +export class MigrationApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code = "MIGRATION_ERROR", + readonly retryable = false, + readonly statusText = "", + readonly rawResponse = "", + ) { + super(message); + this.name = "MigrationApiError"; + } +} + +const FRAMEWORKS = new Set([ + "langchain", + "langgraph", + "adk", + "strands", + "agentcore", + "dify", + "any", +]); + +const TASK_STATES = new Set([ + "awaiting_upload", + "analyzing", + "analysis_ready", + "migrating", + "validating", + "packaging", + "succeeded", + "succeeded_with_warnings", + "partial", + "failed", + "cancelled", + "expired", +]); + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label}格式错误。`); + } + return value as Record; +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error(`${label}格式错误。`); + } + return value; +} + +function framework(value: unknown, label: string): MigrationFramework { + if (typeof value !== "string" || !FRAMEWORKS.has(value as MigrationFramework)) { + throw new Error(`${label}格式错误。`); + } + return value as MigrationFramework; +} + +function normalizeAnalysis(value: unknown): MigrationAnalysis { + const analysis = record(value, "迁移分析结果"); + const recommended = record(analysis.recommended, "迁移建议"); + const boundary = record(analysis.boundary, "迁移边界"); + if ( + analysis.schema_version !== 1 || + typeof analysis.summary !== "string" || + !Array.isArray(analysis.frameworks) || + !Array.isArray(analysis.entries) || + !Array.isArray(analysis.questions) + ) { + throw new Error("迁移分析结果格式错误。"); + } + return { + schema_version: 1, + summary: analysis.summary, + frameworks: analysis.frameworks.map((item) => { + const candidate = record(item, "框架候选"); + if ( + !["high", "medium", "low"].includes(String(candidate.confidence)) || + !Array.isArray(candidate.evidence) + ) { + throw new Error("框架候选格式错误。"); + } + return { + id: framework(candidate.id, "框架候选"), + confidence: candidate.confidence as "high" | "medium" | "low", + evidence: candidate.evidence.map((evidenceValue) => { + const evidence = record(evidenceValue, "分析证据"); + if ( + typeof evidence.path !== "string" || + typeof evidence.line !== "number" || + typeof evidence.reason !== "string" + ) { + throw new Error("分析证据格式错误。"); + } + return { + path: evidence.path, + line: evidence.line, + reason: evidence.reason, + }; + }), + }; + }), + recommended: { + framework: framework(recommended.framework, "推荐框架"), + entry: + recommended.entry === null || typeof recommended.entry === "string" + ? recommended.entry + : null, + reason: + typeof recommended.reason === "string" ? recommended.reason : "", + }, + entries: analysis.entries.map((item) => { + const entry = record(item, "入口候选"); + if (typeof entry.value !== "string" || typeof entry.evidence !== "string") { + throw new Error("入口候选格式错误。"); + } + return { + value: entry.value, + framework: framework(entry.framework, "入口框架"), + evidence: entry.evidence, + }; + }), + boundary: { + include: stringArray(boundary.include, "迁移包含范围"), + exclude: stringArray(boundary.exclude, "迁移排除范围"), + }, + questions: analysis.questions.map((item) => { + const question = record(item, "待确认问题"); + if ( + typeof question.id !== "string" || + typeof question.prompt !== "string" || + typeof question.required !== "boolean" + ) { + throw new Error("待确认问题格式错误。"); + } + return { + id: question.id, + prompt: question.prompt, + required: question.required, + }; + }), + warnings: stringArray(analysis.warnings, "迁移警告"), + }; +} + +function normalizeTask(value: unknown): MigrationTask { + const task = record(value, "迁移会话"); + const artifact = record(task.artifact, "迁移产物状态"); + if ( + typeof task.id !== "string" || + typeof task.state !== "string" || + !TASK_STATES.has(task.state as MigrationTaskState) || + typeof task.message !== "string" || + typeof task.sourceFileName !== "string" || + typeof task.instruction !== "string" || + (typeof task.createdAt !== "string" && typeof task.createdAt !== "number") || + typeof task.expiresAt !== "string" || + typeof task.sessionTtlSeconds !== "number" || + typeof task.canModify !== "boolean" || + typeof task.canUpload !== "boolean" || + typeof task.canConfirm !== "boolean" || + typeof task.canStop !== "boolean" + ) { + throw new Error("迁移会话格式错误。"); + } + const normalized: MigrationTask = { + id: task.id, + state: task.state as MigrationTaskState, + message: task.message, + sourceFileName: task.sourceFileName, + instruction: task.instruction, + createdAt: task.createdAt, + expiresAt: task.expiresAt, + sessionTtlSeconds: task.sessionTtlSeconds, + canModify: task.canModify, + canUpload: task.canUpload, + canConfirm: task.canConfirm, + canStop: task.canStop, + artifact: { + state: typeof artifact.state === "string" ? artifact.state : "none", + previewReady: artifact.previewReady === true, + downloadReady: artifact.downloadReady === true, + deployReady: artifact.deployReady === true, + }, + }; + if (task.analysis !== undefined) normalized.analysis = normalizeAnalysis(task.analysis); + if (task.confirmation !== undefined) { + const confirmation = record(task.confirmation, "迁移确认"); + normalized.confirmation = { + ...(confirmation.framework !== undefined + ? { framework: framework(confirmation.framework, "确认框架") } + : {}), + ...(confirmation.entry === null || typeof confirmation.entry === "string" + ? { entry: confirmation.entry } + : {}), + ...(typeof confirmation.app_name === "string" + ? { app_name: confirmation.app_name } + : {}), + }; + } + if (task.error !== undefined) { + const error = record(task.error, "迁移错误"); + normalized.error = { + code: typeof error.code === "string" ? error.code : "MIGRATION_ERROR", + message: typeof error.message === "string" ? error.message : task.message, + retryable: error.retryable === true, + }; + } + return normalized; +} + +function normalizeArtifact(value: unknown): MigrationArtifact { + const artifact = record(value, "迁移产物"); + const cli = record(artifact.cli, "CLI 信息"); + const migration = record(artifact.migration, "迁移信息"); + const startup = record(artifact.startup, "启动信息"); + const environment = record(artifact.environment, "环境变量信息"); + const verification = record(artifact.verification, "校验信息"); + const report = record(artifact.report, "迁移报告"); + const descriptor = record(artifact.artifact, "产物归档"); + if ( + artifact.schema_version !== 1 || + !["succeeded", "succeeded_with_warnings", "partial"].includes( + String(artifact.status), + ) || + typeof cli.name !== "string" || + typeof cli.version !== "string" || + !["structured", "agentic"].includes(String(migration.engine)) || + typeof migration.framework !== "string" || + !Array.isArray(artifact.files) || + typeof startup.module !== "string" || + typeof startup.object !== "string" || + !["passed", "failed", "degraded"].includes(String(verification.status)) || + !Array.isArray(verification.checks) || + typeof report.path !== "string" || + descriptor.path !== "migration-result.zip" || + typeof descriptor.size !== "number" || + typeof descriptor.sha256 !== "string" || + typeof artifact.created_at !== "string" + ) { + throw new Error("迁移产物格式错误。"); + } + return { + schema_version: 1, + ...(typeof artifact.run_id === "string" ? { run_id: artifact.run_id } : {}), + cli: { name: cli.name, version: cli.version }, + migration: { + engine: migration.engine as "structured" | "agentic", + framework: migration.framework, + ...(typeof migration.entry === "string" ? { entry: migration.entry } : {}), + ...(typeof migration.source_sha256 === "string" + ? { source_sha256: migration.source_sha256 } + : {}), + ...(typeof migration.provenance_sha256 === "string" + ? { provenance_sha256: migration.provenance_sha256 } + : {}), + }, + status: artifact.status as MigrationArtifact["status"], + files: artifact.files.map((item) => { + const file = record(item, "迁移产物文件"); + if ( + typeof file.path !== "string" || + typeof file.size !== "number" || + typeof file.sha256 !== "string" || + typeof file.mode !== "string" + ) { + throw new Error("迁移产物文件格式错误。"); + } + return { + path: file.path, + size: file.size, + sha256: file.sha256, + mode: file.mode, + }; + }), + startup: { + module: startup.module, + object: startup.object, + ...(Array.isArray(startup.command) && + startup.command.every((item) => typeof item === "string") + ? { command: startup.command as string[] } + : {}), + }, + environment: { + required: stringArray(environment.required, "必需环境变量"), + optional: stringArray(environment.optional, "可选环境变量"), + }, + verification: { + status: verification.status as MigrationArtifact["verification"]["status"], + checks: verification.checks.map((item) => { + const check = record(item, "迁移校验项"); + if ( + typeof check.name !== "string" || + !["passed", "failed"].includes(String(check.status)) + ) { + throw new Error("迁移校验项格式错误。"); + } + return { + name: check.name, + status: check.status as "passed" | "failed", + ...(typeof check.detail === "string" ? { detail: check.detail } : {}), + }; + }), + }, + warnings: stringArray(artifact.warnings, "迁移产物警告"), + report: { path: report.path }, + artifact: { + path: "migration-result.zip", + size: descriptor.size, + sha256: descriptor.sha256, + }, + created_at: artifact.created_at, + }; +} + +async function request( + path: string, + init: RequestInit = {}, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, +): Promise { + return fetch(withAuth(`${API_ROOT}${path}`), { + ...init, + headers: withLocalUser(init.headers), + signal: requestSignal(init.signal, timeoutMs), + }); +} + +function validationErrorDetail(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return ""; + const detail = item as Record; + const location = Array.isArray(detail.loc) + ? detail.loc + .filter( + (part): part is string | number => + typeof part === "string" || typeof part === "number", + ) + .join(".") + : ""; + const message = typeof detail.msg === "string" ? detail.msg : ""; + if (!message) return ""; + return location ? `${location}: ${message}` : message; + }) + .filter(Boolean) + .join(";"); +} + +async function errorFrom( + response: Response, + fallback: string, +): Promise { + const text = await response.text().catch(() => ""); + try { + const body = record(JSON.parse(text), "错误响应"); + if (Array.isArray(body.detail)) { + const detail = validationErrorDetail(body.detail); + return new MigrationApiError( + detail ? `请求参数校验失败:${detail}` : fallback, + response.status, + "MIGRATION_REQUEST_INVALID", + false, + response.statusText, + text, + ); + } + if (typeof body.detail === "string") { + return new MigrationApiError( + body.detail, + response.status, + typeof body.code === "string" ? body.code : "MIGRATION_ERROR", + body.retryable === true, + response.statusText, + text, + ); + } + const detail = + body.detail && typeof body.detail === "object" + ? record(body.detail, "错误详情") + : body; + return new MigrationApiError( + typeof detail.message === "string" ? detail.message : fallback, + response.status, + typeof detail.code === "string" ? detail.code : "MIGRATION_ERROR", + detail.retryable === true, + response.statusText, + text, + ); + } catch { + const contentType = + response.headers.get("content-type")?.split(";", 1)[0] || + "Content-Type 缺失"; + return new MigrationApiError( + `${fallback}(HTTP ${response.status},Content-Type: ${contentType})。请检查代理或网关配置。`, + response.status, + "MIGRATION_ERROR", + false, + response.statusText, + text, + ); + } +} + +async function json(response: Response, fallback: string): Promise { + if (!response.ok) throw await errorFrom(response, fallback); + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + throw new MigrationApiError( + `${fallback}:服务端返回非 JSON 响应(HTTP ${response.status})。请检查代理或网关配置。`, + response.status, + "MIGRATION_RESPONSE_INVALID", + false, + response.statusText, + ); + } + return response.json(); +} + +export async function getMigrationCapabilities( + signal?: AbortSignal, +): Promise { + const body = record( + await json( + await request("/capabilities", { signal }), + "读取迁移能力失败", + ), + "迁移能力", + ); + if ( + typeof body.enabled !== "boolean" || + typeof body.reason !== "string" || + typeof body.maxUploadBytes !== "number" || + typeof body.sessionTtlSeconds !== "number" || + !Array.isArray(body.frameworks) + ) { + throw new Error("迁移能力格式错误。"); + } + return { + enabled: body.enabled, + reason: body.reason, + maxUploadBytes: body.maxUploadBytes, + sessionTtlSeconds: body.sessionTtlSeconds, + frameworks: body.frameworks.map((item) => framework(item, "迁移框架")), + }; +} + +export async function listMigrationTasks( + signal?: AbortSignal, +): Promise { + const body = record( + await json(await request("/tasks", { signal }), "读取迁移会话失败"), + "迁移会话列表", + ); + if (!Array.isArray(body.items)) throw new Error("迁移会话列表格式错误。"); + return body.items.map(normalizeTask); +} + +export async function createMigrationTask(args: { + taskId: string; + sourceFileName: string; + instruction: string; + signal?: AbortSignal; +}): Promise { + return normalizeTask( + await json( + await request( + "/tasks", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskId: args.taskId, + sourceFileName: args.sourceFileName, + instruction: args.instruction, + }), + signal: args.signal, + }, + SESSION_START_TIMEOUT_MS, + ), + "创建迁移会话失败", + ), + ); +} + +export async function uploadMigrationSource( + taskId: string, + file: File, + signal?: AbortSignal, +): Promise { + return normalizeTask( + await json( + await request( + `/tasks/${encodeURIComponent(taskId)}/source`, + { + method: "PUT", + headers: { "Content-Type": "application/zip" }, + body: file, + signal, + }, + SESSION_START_TIMEOUT_MS, + ), + "上传迁移项目失败", + ), + ); +} + +export async function getMigrationTask( + taskId: string, + signal?: AbortSignal, +): Promise { + return normalizeTask( + await json( + await request(`/tasks/${encodeURIComponent(taskId)}`, { signal }), + "读取迁移会话失败", + ), + ); +} + +export async function confirmMigrationTask(args: { + taskId: string; + framework: MigrationFramework; + entry?: string; + appName: string; + instruction: string; + answers: Record; + signal?: AbortSignal; +}): Promise { + return normalizeTask( + await json( + await request( + `/tasks/${encodeURIComponent(args.taskId)}/confirm`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + framework: args.framework, + entry: args.entry || null, + appName: args.appName, + instruction: args.instruction, + answers: args.answers, + }), + signal: args.signal, + }, + SESSION_START_TIMEOUT_MS, + ), + "启动迁移失败", + ), + ); +} + +export async function stopMigrationTask( + taskId: string, + signal?: AbortSignal, +): Promise { + return normalizeTask( + await json( + await request( + `/tasks/${encodeURIComponent(taskId)}/stop`, + { method: "POST", signal }, + ), + "终止迁移失败", + ), + ); +} + +export async function deleteMigrationTask( + taskId: string, + signal?: AbortSignal, +): Promise { + await json( + await request( + `/tasks/${encodeURIComponent(taskId)}`, + { method: "DELETE", signal }, + ), + "删除迁移会话失败", + ); +} + +export async function getMigrationArtifact( + taskId: string, + signal?: AbortSignal, +): Promise { + return normalizeArtifact( + await json( + await request( + `/tasks/${encodeURIComponent(taskId)}/artifact`, + { signal }, + ), + "读取迁移产物失败", + ), + ); +} + +export async function getMigrationArtifactFile( + taskId: string, + path: string, + signal?: AbortSignal, +): Promise<{ blob: Blob; mimeType: string }> { + const query = new URLSearchParams({ path }); + const response = await request( + `/tasks/${encodeURIComponent(taskId)}/artifact/file?${query}`, + { signal }, + TRANSFER_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) throw await errorFrom(response, "读取迁移产物文件失败"); + return { + blob: await response.blob(), + mimeType: + response.headers.get("content-type")?.split(";", 1)[0] || + "application/octet-stream", + }; +} + +function responseFilename(response: Response, fallback: string): string { + const disposition = response.headers.get("content-disposition") || ""; + return disposition.match(/filename="([^"]+)"/)?.[1] || fallback; +} + +export async function downloadMigrationArtifact( + taskId: string, + fallbackName: string, + signal?: AbortSignal, +): Promise { + const response = await request( + `/tasks/${encodeURIComponent(taskId)}/download`, + { signal }, + TRANSFER_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) throw await errorFrom(response, "下载迁移产物失败"); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = url; + link.download = responseFilename(response, `${fallbackName}-migrated.zip`); + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); +} diff --git a/frontend/src/adk/telemetryEvents.ts b/frontend/src/adk/telemetryEvents.ts index eb4a3071..d3abf23b 100644 --- a/frontend/src/adk/telemetryEvents.ts +++ b/frontend/src/adk/telemetryEvents.ts @@ -13,6 +13,7 @@ import type { SandboxAgentKind } from "./sandbox"; export type DeploymentTelemetrySource = | "scratch" | "code_package" + | "migration" | "feishu_automation" | "unknown"; @@ -23,6 +24,7 @@ export type DeploymentCreateMode = | "workflow" | "yaml_import" | "code_package" + | "migration" | "feishu_template" | "unknown"; diff --git a/frontend/src/create/CodePackageCreate.tsx b/frontend/src/create/CodePackageCreate.tsx index d7649eb1..22780053 100644 --- a/frontend/src/create/CodePackageCreate.tsx +++ b/frontend/src/create/CodePackageCreate.tsx @@ -5,6 +5,7 @@ import { type ChangeEvent, type DragEvent, } from "react"; +import { parse } from "yaml"; import { deployAgentkitProject, type DeployStage } from "../adk/client"; import { defaultCloudRegion, @@ -81,12 +82,62 @@ export function normalizePackageEntries(entries: ZipEntry[]): ProjectFile[] { if (paths.has(file.path)) throw new Error(`代码包包含重复文件:${file.path}`); paths.add(file.path); } - if (!paths.has("app.py")) { - throw new Error("代码包根目录必须包含 app.py,作为 AgentKit 启动入口。"); - } + resolvePackageEntryPoint(files); return files; } +export function resolvePackageEntryPoint(files: ProjectFile[]): string { + const paths = new Set(files.map((file) => file.path)); + const manifest = files.find((file) => file.path === "agentkit.yaml"); + let entryPoint = "app.py"; + if (manifest) { + let value: unknown; + try { + value = parse(manifest.content); + } catch (cause) { + throw new Error( + `agentkit.yaml 无法解析:${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (value !== null && (typeof value !== "object" || Array.isArray(value))) { + throw new Error("agentkit.yaml 根节点必须是对象。"); + } + const common = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record).common + : undefined; + if ( + common !== undefined && + (common === null || typeof common !== "object" || Array.isArray(common)) + ) { + throw new Error("agentkit.yaml 的 common 必须是对象。"); + } + const configured = + common && typeof common === "object" && !Array.isArray(common) + ? (common as Record).entry_point + : undefined; + if (configured !== undefined) { + if (typeof configured !== "string") { + throw new Error("agentkit.yaml 的 common.entry_point 必须是文件路径。"); + } + const cleaned = cleanEntryPath(configured); + if (!cleaned) { + throw new Error("agentkit.yaml 的 common.entry_point 不是有效文件路径。"); + } + entryPoint = cleaned; + } + } + if (!paths.has(entryPoint)) { + if (manifest && entryPoint !== "app.py") { + throw new Error(`代码包中不存在 agentkit.yaml 声明的启动入口:${entryPoint}`); + } + throw new Error( + "代码包根目录必须包含 app.py,或在 agentkit.yaml 的 common.entry_point 中声明已有入口。", + ); + } + return entryPoint; +} + export function CodePackageCreate({ onBack, onAgentAdded, @@ -246,7 +297,7 @@ export function CodePackageCreate({ {project ? `已识别 ${project.files.length} 个文件,点击区域可重新上传` - : "点击或拖拽上传,支持 .zip 格式,最大 50 MB,根目录需包含 app.py"} + : "点击或拖拽上传,支持 .zip 格式,最大 50 MB;可使用 app.py,或由 agentkit.yaml 声明入口"}
{project && ( diff --git a/frontend/src/migrations/MigrationIcons.tsx b/frontend/src/migrations/MigrationIcons.tsx new file mode 100644 index 00000000..5dce0c81 --- /dev/null +++ b/frontend/src/migrations/MigrationIcons.tsx @@ -0,0 +1,88 @@ +import type { SVGProps } from "react"; + +type IconProps = SVGProps; + +function Icon({ + children, + ...props +}: IconProps & { children: React.ReactNode }) { + return ( + + ); +} + +export function BackIcon(props: IconProps) { + return ( + + + + ); +} + +export function DownloadIcon(props: IconProps) { + return ( + + + + ); +} + +export function FileIcon(props: IconProps) { + return ( + + + + ); +} + +export function PlusIcon(props: IconProps) { + return ( + + + + ); +} + +export function DeployIcon(props: IconProps) { + return ( + + + + ); +} + +export function SendIcon(props: IconProps) { + return ( + + + + ); +} + +export function UploadIcon(props: IconProps) { + return ( + + + + ); +} + +export function CloseIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/migrations/MigrationWorkspace.css b/frontend/src/migrations/MigrationWorkspace.css new file mode 100644 index 00000000..bc27086f --- /dev/null +++ b/frontend/src/migrations/MigrationWorkspace.css @@ -0,0 +1,1118 @@ +.migration-workspace { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: 244px minmax(0, 1fr); + overflow: hidden; + background: hsl(var(--canvas)); + color: hsl(var(--foreground)); +} + +.migration-history { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: 54px auto minmax(0, 1fr); + padding: 0 10px 12px; + overflow: hidden; + border-right: 1px solid hsl(var(--border)); + background: hsl(var(--panel)); +} + +.migration-history > header { + display: flex; + align-items: center; + gap: 8px; + padding: 0 2px; +} + +.migration-history h1 { + margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 1.4; +} + +.migration-icon-button { + width: 32px; + height: 32px; + flex: 0 0 32px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.migration-icon-button:hover { + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} + +.migration-icon-button svg, +.migration-new-button svg, +.migration-file-chip svg, +.migration-composer__file svg, +.migration-attach-button svg, +.migration-result__actions svg { + width: 17px; + height: 17px; + flex: 0 0 auto; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + +.migration-new-button { + min-height: 36px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin: 4px 2px 10px; + padding: 0 10px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 12.5px; + font-weight: 550; + cursor: pointer; +} + +.migration-new-button:hover:not(:disabled) { + background: hsl(var(--secondary)); +} + +.migration-new-button:disabled, +.migration-history nav > button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.migration-history nav { + min-height: 0; + display: flex; + flex-direction: column; + gap: 3px; + overflow-y: auto; +} + +.migration-history nav > button { + width: 100%; + min-height: 56px; + display: grid; + align-content: center; + gap: 5px; + padding: 8px 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: hsl(var(--foreground)); + font: inherit; + text-align: left; + cursor: pointer; +} + +.migration-history nav > button:hover:not(:disabled), +.migration-history nav > button.is-active { + background: hsl(var(--secondary)); +} + +.migration-history nav > button > span { + overflow: hidden; + font-size: 12.5px; + font-weight: 540; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-history nav small { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: hsl(var(--muted-foreground)); + font-size: 10.5px; +} + +.migration-history nav small span[data-state="failed"], +.migration-history nav small span[data-state="expired"] { + color: hsl(var(--destructive)); +} + +.migration-history nav small span[data-state="succeeded"], +.migration-history nav small span[data-state="succeeded_with_warnings"] { + color: hsl(150 52% 34%); +} + +.migration-history__empty { + margin: 16px 8px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + text-align: center; +} + +.migration-main { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: 64px minmax(0, 1fr) auto; + overflow: hidden; + background: hsl(var(--background)); +} + +.migration-main__header { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 24px; + border-bottom: 1px solid hsl(var(--border)); +} + +.migration-main__header > div { + min-width: 0; +} + +.migration-main__header h2 { + margin: 0; + overflow: hidden; + font-size: 15px; + font-weight: 600; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-main__header p { + margin: 3px 0 0; + overflow: hidden; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-ttl { + flex: 0 0 auto; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; +} + +.migration-conversation { + min-height: 0; + overflow-y: auto; + padding: 28px max(24px, calc((100% - 820px) / 2)) 40px; + scrollbar-gutter: stable; +} + +.migration-turn { + width: 100%; + max-width: 768px; + display: flex; + gap: 12px; + margin: 0 auto 24px; +} + +.migration-turn.is-user { + justify-content: flex-end; +} + +.migration-assistant-mark { + width: 28px; + height: 28px; + flex: 0 0 28px; + display: grid; + place-items: center; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--panel)); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 650; +} + +.migration-turn.is-assistant > div:last-child, +.migration-assistant-content { + min-width: 0; + flex: 1; + color: hsl(var(--foreground)); + font-size: 13.5px; + line-height: 1.65; + overflow-wrap: anywhere; +} + +.migration-turn p { + margin: 0; +} + +.migration-turn small { + display: block; + margin-top: 6px; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; +} + +.migration-user-message { + min-width: 0; + max-width: min(640px, 82%); + display: grid; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + background: hsl(var(--secondary)); + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.migration-file-chip, +.migration-composer__file { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.migration-file-chip { + font-size: 12px; + font-weight: 550; +} + +.migration-file-chip > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-running-note { + margin-top: 8px !important; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.migration-analysis { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.migration-analysis__facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 24px; + padding: 14px 0; + border-top: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); +} + +.migration-analysis h3 { + margin: 0 0 5px; + color: hsl(var(--muted-foreground)); + font-size: 11px; + font-weight: 550; +} + +.migration-analysis strong { + font-size: 13px; + font-weight: 600; +} + +.migration-analysis__facts p, +.migration-analysis__facts ul { + margin: 4px 0 0; + padding: 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + overflow-wrap: anywhere; +} + +.migration-analysis__facts ul { + padding-left: 17px; +} + +.migration-analysis__evidence summary { + width: max-content; + color: hsl(var(--muted-foreground)); + font-size: 12px; + cursor: pointer; +} + +.migration-analysis__evidence ul { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.migration-analysis__evidence li { + display: grid; + gap: 3px; + font-size: 11.5px; +} + +.migration-analysis__evidence code { + color: hsl(var(--foreground)); + overflow-wrap: anywhere; +} + +.migration-analysis__evidence span, +.migration-analysis__warnings { + color: hsl(var(--muted-foreground)); +} + +.migration-analysis__warnings { + padding: 10px 12px; + border: 1px solid hsl(38 80% 52% / 0.25); + border-radius: 8px; + background: hsl(38 85% 55% / 0.07); + font-size: 12px; +} + +.migration-confirmation, +.migration-result { + width: 100%; + max-width: 768px; + box-sizing: border-box; + margin: 0 auto 24px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--panel)); +} + +.migration-confirmation { + display: grid; + gap: 16px; + padding: 18px; +} + +.migration-confirmation__heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.migration-confirmation__heading strong { + font-size: 15px; + font-weight: 600; +} + +.migration-confirmation__heading span { + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + text-align: right; + overflow-wrap: anywhere; +} + +.migration-confirmation__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.migration-confirmation .new-chat-compact-select { + align-self: end; +} + +.migration-confirmation .new-chat-compact-select__trigger { + width: 100%; + min-height: 36px; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + background: hsl(var(--background)); +} + +.migration-confirmation .new-chat-compact-select__menu { + width: 100%; +} + +.migration-field { + min-width: 0; + display: grid; + gap: 6px; + color: hsl(var(--foreground)); + font-size: 12px; + font-weight: 550; + overflow-wrap: anywhere; +} + +.migration-field b { + margin-left: 3px; + color: hsl(var(--destructive)); +} + +.migration-field input, +.migration-field textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + outline: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 13px; + font-weight: 400; +} + +.migration-field input { + height: 36px; + padding: 0 10px; +} + +.migration-field textarea { + min-height: 78px; + padding: 9px 10px; + line-height: 1.5; + resize: vertical; +} + +.migration-field input:focus, +.migration-field textarea:focus { + border-color: hsl(var(--ring) / 0.7); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.12); +} + +.migration-field input:disabled, +.migration-field textarea:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.migration-field small { + color: hsl(var(--destructive)); + font-size: 11px; + font-weight: 400; +} + +.migration-confirmation__actions { + display: flex; + justify-content: flex-end; +} + +.migration-primary-button, +.migration-result__actions button, +.migration-running-actions button, +.migration-inline-error button { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 12.5px; + font-weight: 550; + cursor: pointer; +} + +.migration-primary-button, +.migration-result__actions button.is-primary { + border-color: hsl(var(--foreground)); + background: hsl(var(--foreground)); + color: hsl(var(--background)); +} + +.migration-primary-button:disabled, +.migration-result__actions button:disabled, +.migration-running-actions button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.migration-result { + overflow: hidden; +} + +.migration-result > header { + min-height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 10px 14px; + border-bottom: 1px solid hsl(var(--border)); +} + +.migration-result > header > div:first-child { + min-width: 0; + flex: 1 1 280px; + display: grid; + gap: 3px; +} + +.migration-result > header strong { + font-size: 14px; + font-weight: 600; +} + +.migration-result > header span { + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + overflow-wrap: anywhere; +} + +.migration-result__actions { + flex: 0 0 auto; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} + +.migration-result__actions button span { + color: inherit; +} + +.migration-result__summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 18px; + padding: 9px 14px; + border-bottom: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); + font-size: 11px; +} + +.migration-result__summary span { + min-width: 0; + overflow-wrap: anywhere; +} + +.migration-artifact-browser { + height: min(54vh, 560px); + min-height: 360px; + display: grid; + grid-template-columns: 224px minmax(0, 1fr); +} + +.migration-artifact-browser > aside { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border-right: 1px solid hsl(var(--border)); +} + +.migration-artifact-browser__search { + padding: 8px; +} + +.migration-artifact-browser__search input { + width: 100%; + height: 32px; + box-sizing: border-box; + padding: 0 9px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + outline: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 12px; +} + +.migration-artifact-browser__files { + min-height: 0; + overflow-y: auto; +} + +.migration-artifact-browser__files button { + width: 100%; + min-height: 32px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 0; + background: transparent; + color: hsl(var(--foreground)); + font: inherit; + font-size: 11.5px; + text-align: left; + cursor: pointer; +} + +.migration-artifact-browser__files button:hover, +.migration-artifact-browser__files button.is-active { + background: hsl(var(--secondary)); +} + +.migration-artifact-browser__files svg { + width: 15px; + height: 15px; + stroke: currentColor; + stroke-width: 1.65; + stroke-linecap: round; + stroke-linejoin: round; +} + +.migration-artifact-browser__files span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-artifact-browser__files small, +.migration-artifact-browser__limit { + color: hsl(var(--muted-foreground)); + font-size: 10px; +} + +.migration-artifact-browser__limit { + margin: 0; + padding: 7px 9px; + border-top: 1px solid hsl(var(--border)); +} + +.migration-artifact-browser > section { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); +} + +.migration-artifact-browser > section > header { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid hsl(var(--border)); + font-size: 11.5px; +} + +.migration-artifact-browser > section > header span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-artifact-browser > section > header small { + flex: 0 0 auto; + color: hsl(var(--muted-foreground)); +} + +.migration-artifact-browser__preview { + min-width: 0; + min-height: 0; + display: grid; + place-items: stretch; + overflow: hidden; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.migration-artifact-browser__preview > .cm-theme-light, +.migration-artifact-browser__preview .cm-editor { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.migration-artifact-browser__preview .cm-scroller { + overflow: auto; +} + +.migration-artifact-browser__preview > p, +.migration-artifact-browser__preview > .text-shimmer, +.migration-artifact-browser__preview > img { + place-self: center; +} + +.migration-artifact-browser__preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.migration-system-state, +.migration-inline-error { + width: 100%; + max-width: 768px; + box-sizing: border-box; + margin: 0 auto 20px; + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + line-height: 1.5; +} + +.migration-system-state.is-error, +.migration-inline-error { + border: 1px solid hsl(var(--destructive) / 0.2); + background: hsl(var(--destructive) / 0.06); + color: hsl(var(--destructive)); +} + +.migration-system-state strong { + font-size: 13px; + font-weight: 600; +} + +.migration-system-state p { + margin: 4px 0 0; +} + +.migration-retry-button { + min-height: 30px; + margin-top: 8px; + padding: 0 10px; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 11.5px; + cursor: pointer; +} + +.migration-retry-button:hover { + background: hsl(var(--secondary)); +} + +.migration-inline-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.migration-inline-error > span { + min-width: 0; + overflow-wrap: anywhere; +} + +.migration-inline-error button { + min-height: 30px; + flex: 0 0 auto; + color: inherit; + font-size: 11.5px; +} + +.migration-inline-error button[aria-label] { + width: 30px; + padding: 0; + border: 0; + background: transparent; +} + +.migration-inline-error button svg { + width: 15px; + height: 15px; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; +} + +.migration-expired { + display: grid; + gap: 4px; +} + +.migration-composer { + width: min(768px, calc(100% - 48px)); + justify-self: center; + padding: 0 0 18px; +} + +.migration-composer__box { + min-height: 132px; + position: relative; + display: grid; + grid-template-rows: auto minmax(64px, auto) 36px; + gap: 7px; + box-sizing: border-box; + padding: 12px; + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: hsl(var(--panel)); + box-shadow: 0 10px 34px -24px hsl(var(--foreground) / 0.35); +} + +.migration-composer__box.is-dragging { + border-color: hsl(var(--ring) / 0.7); + background: hsl(var(--secondary) / 0.35); +} + +.migration-composer__box textarea { + width: 100%; + min-height: 64px; + padding: 0; + border: 0; + outline: 0; + resize: none; + background: transparent; + color: hsl(var(--foreground)); + font: inherit; + font-size: 14px; + line-height: 1.55; +} + +.migration-composer__box > p { + margin: 8px 2px; + color: hsl(var(--muted-foreground)); + font-size: 13px; +} + +.migration-composer__file { + width: max-content; + max-width: 100%; + min-height: 32px; + padding: 0 7px 0 9px; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + background: hsl(var(--secondary)); + font-size: 11.5px; +} + +.migration-composer__file span { + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-composer__file small { + color: hsl(var(--muted-foreground)); +} + +.migration-composer__file button { + width: 26px; + height: 26px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.migration-composer__file button:hover { + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.migration-composer__file button svg { + width: 14px; + height: 14px; +} + +.migration-composer__actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +.migration-attach-button { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: hsl(var(--muted-foreground)); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.migration-attach-button:hover { + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} + +.migration-send-button { + width: 36px; + height: 36px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 50%; + background: hsl(var(--foreground)); + color: hsl(var(--background)); + cursor: pointer; +} + +.migration-send-button:disabled, +.migration-attach-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.migration-send-button svg { + width: 19px; + height: 19px; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + +.migration-composer input[type="file"] { + display: none; +} + +.migration-composer > p { + margin: 7px 4px 0; + color: hsl(var(--muted-foreground)); + font-size: 10.5px; + text-align: center; +} + +.migration-running-actions { + display: flex; + justify-content: center; + padding: 0 24px 18px; +} + +.migration-running-actions button { + color: hsl(var(--destructive)); +} + +.migration-deployment { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; +} + +.migration-deployment > * { + flex: 1; + min-width: 0; + min-height: 0; +} + +.migration-deployment-summary { + display: grid; + gap: 8px; + padding: 18px; +} + +.migration-deployment-summary > strong { + font-size: 15px; + font-weight: 600; +} + +.migration-deployment-summary > span { + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.migration-deployment-summary dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 8px 0 0; +} + +.migration-deployment-summary dl > div { + display: grid; + gap: 4px; +} + +.migration-deployment-summary dt { + color: hsl(var(--muted-foreground)); + font-size: 10.5px; +} + +.migration-deployment-summary dd { + margin: 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.migration-icon-button:focus-visible, +.migration-new-button:focus-visible, +.migration-history button:focus-visible, +.migration-primary-button:focus-visible, +.migration-result__actions button:focus-visible, +.migration-running-actions button:focus-visible, +.migration-attach-button:focus-visible, +.migration-send-button:focus-visible, +.migration-retry-button:focus-visible, +.migration-artifact-browser button:focus-visible { + outline: 2px solid hsl(var(--ring) / 0.5); + outline-offset: 2px; +} + +@media (max-width: 980px) { + .migration-workspace { + grid-template-columns: 210px minmax(0, 1fr); + } + + .migration-main__header { + padding-inline: 18px; + } + + .migration-conversation { + padding-inline: 20px; + } + + .migration-confirmation__grid, + .migration-analysis__facts { + grid-template-columns: minmax(0, 1fr); + } + + .migration-artifact-browser { + height: min(68vh, 640px); + min-height: 480px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 168px minmax(0, 1fr); + } + + .migration-artifact-browser > aside { + border-right: 0; + border-bottom: 1px solid hsl(var(--border)); + } + + .migration-result__actions { + width: 100%; + } + + .migration-result__actions button { + flex: 1 1 auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .migration-icon-button, + .migration-new-button, + .migration-attach-button, + .migration-send-button { + transition: none; + } +} diff --git a/frontend/src/migrations/MigrationWorkspace.tsx b/frontend/src/migrations/MigrationWorkspace.tsx new file mode 100644 index 00000000..e767780e --- /dev/null +++ b/frontend/src/migrations/MigrationWorkspace.tsx @@ -0,0 +1,1503 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type DragEvent, +} from "react"; +import { + confirmMigrationTask, + createMigrationTask, + downloadMigrationArtifact, + getMigrationArtifact, + getMigrationArtifactFile, + getMigrationCapabilities, + getMigrationTask, + listMigrationTasks, + MigrationApiError, + stopMigrationTask, + uploadMigrationSource, + type MigrationAnalysis, + type MigrationArtifact, + type MigrationCapabilities, + type MigrationFramework, + type MigrationTask, +} from "../adk/migrations"; +import { + deployAgentkitProject, + type DeployStage, +} from "../adk/client"; +import { + defaultCloudRegion, + type CloudProvider, +} from "../adk/cloudProvider"; +import type { AgentProject } from "../create/project"; +import type { NetworkConfig } from "../create/types"; +import type { EnvVar } from "../create/veadkCatalog"; +import CodeEditor from "../ui/CodeEditor"; +import { Markdown } from "../ui/Markdown"; +import { StudioConfirmDialog } from "../ui/StudioConfirmDialog"; +import { NewChatCompactSelect } from "../ui/new-chat-modes/NewChatCompactSelect"; +import { + ProjectPreview, + type DeployResult, + type DeploymentTaskUpdate, +} from "../ui/ProjectPreview"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { isImeCompositionEvent } from "../ui/composerKeyboard"; +import { + BackIcon, + CloseIcon, + DeployIcon, + DownloadIcon, + FileIcon, + PlusIcon, + SendIcon, + UploadIcon, +} from "./MigrationIcons"; +import "./MigrationWorkspace.css"; + +const MAX_SOURCE_BYTES = 50 * 1024 * 1024; +const POLL_INTERVAL_MS = 1_200; +const LIST_POLL_INTERVAL_MS = 5_000; +const MAX_VISIBLE_FILES = 500; + +const FRAMEWORK_LABELS: Record = { + langchain: "LangChain", + langgraph: "LangGraph", + adk: "Google ADK", + strands: "Strands", + agentcore: "AgentCore", + dify: "Dify", + any: "Any / 其他项目", +}; + +const STRUCTURED_FRAMEWORKS = new Set([ + "langchain", + "langgraph", + "adk", + "strands", + "agentcore", +]); + +interface MigrationWorkspaceProps { + cloudProvider: CloudProvider; + onBack: () => void; + onAgentAdded?: (agentId: string, agentName: string) => void; + onDeploymentTaskChange?: (task: DeploymentTaskUpdate) => void; + onDeploymentStarted?: (task: DeploymentTaskUpdate) => void; + onDeploymentComplete?: (result: DeployResult) => void | Promise; + initialDeployRegion?: string; +} + +interface PreviewState { + path: string; + loading: boolean; + text?: string; + imageUrl?: string; + error?: string; +} + +function stateLabel(state: MigrationTask["state"]): string { + switch (state) { + case "awaiting_upload": + return "待上传"; + case "analyzing": + return "分析中"; + case "analysis_ready": + return "待确认"; + case "migrating": + return "迁移中"; + case "validating": + return "校验中"; + case "packaging": + return "打包中"; + case "succeeded": + return "已完成"; + case "succeeded_with_warnings": + return "已完成,有提示"; + case "partial": + return "部分完成"; + case "failed": + return "失败"; + case "cancelled": + return "已终止"; + case "expired": + return "已过期"; + } +} + +function isActiveState(state: MigrationTask["state"]): boolean { + return ["analyzing", "migrating", "validating", "packaging"].includes(state); +} + +function isTerminalState(state: MigrationTask["state"]): boolean { + return [ + "succeeded", + "succeeded_with_warnings", + "partial", + "failed", + "cancelled", + "expired", + ].includes(state); +} + +function sourceStem(name: string): string { + return name.replace(/\.zip$/i, ""); +} + +function defaultAppName(name: string): string { + let value = sourceStem(name) + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!value || !/^[A-Za-z]/.test(value)) value = `agent-${value || "migration"}`; + return value.slice(0, 64); +} + +function appNameError(value: string): string { + if (!value.trim()) return "请输入 Agent 名称"; + if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value.trim())) { + return "Agent 名称必须以字母开头,且只能包含字母、数字、下划线和连字符"; + } + return ""; +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; + return `${(value / 1024 / 1024).toFixed(1)} MiB`; +} + +function formatDate(value: string | number): string { + const date = + typeof value === "number" + ? new Date(value * 1000) + : new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function remainingLabel(task: MigrationTask, now: number): string { + const expiry = new Date(task.expiresAt).getTime(); + if (!Number.isFinite(expiry)) return "Session TTL 为 1 小时"; + if (task.state === "expired" || now >= expiry) return "已过期"; + const remaining = Math.max(0, expiry - now); + const minutes = Math.floor(remaining / 60_000); + const seconds = Math.floor((remaining % 60_000) / 1_000); + return `剩余 ${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function expireTasksAtDeadline( + tasks: MigrationTask[], + now: number, +): MigrationTask[] { + let changed = false; + const next = tasks.map((task) => { + if (task.state === "expired") return task; + const expiry = new Date(task.expiresAt).getTime(); + if (!Number.isFinite(expiry) || now < expiry) return task; + changed = true; + return { + ...task, + state: "expired" as const, + message: "Dev Sandbox 已超过 1 小时 TTL,迁移内容和产物不可再访问。", + canModify: false, + canUpload: false, + canConfirm: false, + canStop: false, + artifact: { + state: "none", + previewReady: false, + downloadReady: false, + deployReady: false, + }, + }; + }); + return changed ? next : tasks; +} + +function upsertTask( + tasks: MigrationTask[], + task: MigrationTask, +): MigrationTask[] { + const next = tasks.filter((item) => item.id !== task.id); + return [task, ...next].sort((left, right) => { + const leftTime = + typeof left.createdAt === "number" + ? left.createdAt * 1000 + : new Date(left.createdAt).getTime(); + const rightTime = + typeof right.createdAt === "number" + ? right.createdAt * 1000 + : new Date(right.createdAt).getTime(); + return rightTime - leftTime; + }); +} + +function selectedTask( + tasks: MigrationTask[], + taskId: string, +): MigrationTask | null { + return tasks.find((item) => item.id === taskId) ?? null; +} + +function isTextMime(mimeType: string, path: string): boolean { + return ( + mimeType.startsWith("text/") || + /(?:json|javascript|xml|yaml)/i.test(mimeType) || + /\.(?:py|ts|tsx|js|jsx|json|ya?ml|md|txt|toml|ini|cfg|env|sh|dockerfile)$/i.test( + path, + ) + ); +} + +function AnalysisSummary({ analysis }: { analysis: MigrationAnalysis }) { + return ( +
+ +
+
+

建议迁移方式

+ {FRAMEWORK_LABELS[analysis.recommended.framework]} +

{analysis.recommended.reason}

+
+
+

迁移范围

+
    + {analysis.boundary.include.map((item) => ( +
  • {item}
  • + ))} +
+
+ {analysis.boundary.exclude.length > 0 ? ( +
+

不在本次范围

+
    + {analysis.boundary.exclude.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null} +
+ {analysis.frameworks[0]?.evidence.length ? ( +
+ 查看分析证据 +
    + {analysis.frameworks.flatMap((candidate) => + candidate.evidence.map((item) => ( +
  • + {item.path}:{item.line} + {item.reason} +
  • + )), + )} +
+
+ ) : null} + {analysis.warnings.length > 0 ? ( +
+ {analysis.warnings.map((warning) => ( +

{warning}

+ ))} +
+ ) : null} +
+ ); +} + +function ArtifactBrowser({ + task, + artifact, +}: { + task: MigrationTask; + artifact: MigrationArtifact; +}) { + const [query, setQuery] = useState(""); + const [activePath, setActivePath] = useState( + artifact.files[0]?.path ?? "", + ); + const [preview, setPreview] = useState(null); + const activeFile = + artifact.files.find((file) => file.path === activePath) ?? + artifact.files[0]; + const filteredFiles = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + const matches = normalized + ? artifact.files.filter((file) => + file.path.toLocaleLowerCase().includes(normalized), + ) + : artifact.files; + return matches.slice(0, MAX_VISIBLE_FILES); + }, [artifact.files, query]); + + useEffect(() => { + if (!activeFile) return; + if (activeFile.size > 2 * 1024 * 1024) { + setPreview({ + path: activeFile.path, + loading: false, + error: "该文件超过 2 MiB,请下载完整产物后查看。", + }); + return; + } + const controller = new AbortController(); + let objectUrl = ""; + setPreview({ path: activeFile.path, loading: true }); + void getMigrationArtifactFile( + task.id, + activeFile.path, + controller.signal, + ) + .then(async ({ blob, mimeType }) => { + if (controller.signal.aborted) return; + if (mimeType.startsWith("image/")) { + objectUrl = URL.createObjectURL(blob); + setPreview({ + path: activeFile.path, + loading: false, + imageUrl: objectUrl, + }); + return; + } + if (isTextMime(mimeType, activeFile.path)) { + const text = await blob.text(); + if (controller.signal.aborted) return; + setPreview({ + path: activeFile.path, + loading: false, + text, + }); + return; + } + setPreview({ + path: activeFile.path, + loading: false, + error: "该文件不支持在线预览,请下载完整产物后查看。", + }); + }) + .catch((cause: unknown) => { + if (controller.signal.aborted) return; + setPreview({ + path: activeFile.path, + loading: false, + error: cause instanceof Error ? cause.message : String(cause), + }); + }); + return () => { + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [activeFile, task.id]); + + return ( +
+ +
+
+ {activeFile?.path || "未选择文件"} + {activeFile ? {formatBytes(activeFile.size)} : null} +
+
+ {!activeFile ? ( +

暂无可预览文件。

+ ) : preview?.path !== activeFile.path || preview.loading ? ( + 正在读取产物文件… + ) : preview.error ? ( +

{preview.error}

+ ) : preview.imageUrl ? ( + {activeFile.path} + ) : ( + undefined} + /> + )} +
+
+
+ ); +} + +export function MigrationWorkspace({ + cloudProvider, + onBack, + onAgentAdded, + onDeploymentTaskChange, + onDeploymentStarted, + onDeploymentComplete, + initialDeployRegion = defaultCloudRegion(cloudProvider), +}: MigrationWorkspaceProps) { + const fileInputRef = useRef(null); + const confirmationTaskRef = useRef(""); + const transferAbortRef = useRef(null); + const [capability, setCapability] = + useState(null); + const [tasks, setTasks] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(""); + const [sourceFile, setSourceFile] = useState(null); + const [instruction, setInstruction] = useState(""); + const [dragging, setDragging] = useState(false); + const [loading, setLoading] = useState(true); + const [action, setAction] = useState< + "create" | "upload" | "confirm" | "stop" | "download" | "" + >(""); + const [error, setError] = useState(""); + const [pollError, setPollError] = useState(""); + const [now, setNow] = useState(Date.now()); + const [framework, setFramework] = + useState("langchain"); + const [entry, setEntry] = useState(""); + const [appName, setAppName] = useState(""); + const [additionalInstruction, setAdditionalInstruction] = useState(""); + const [answers, setAnswers] = useState>({}); + const [artifact, setArtifact] = useState(null); + const [artifactError, setArtifactError] = useState(""); + const [artifactErrorRetryable, setArtifactErrorRetryable] = useState(false); + const [artifactReload, setArtifactReload] = useState(0); + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [deploymentOpen, setDeploymentOpen] = useState(false); + const [deployRegion, setDeployRegion] = useState(initialDeployRegion); + const [network, setNetwork] = useState(); + const [deploymentEnvValues, setDeploymentEnvValues] = useState< + Record + >({}); + const task = selectedTask(tasks, selectedTaskId); + + async function reconcileTaskState( + taskId: string, + surfaceError = true, + signal?: AbortSignal, + ) { + try { + const authoritative = await getMigrationTask(taskId, signal); + if (signal?.aborted) return null; + setTasks((current) => upsertTask(current, authoritative)); + setPollError(""); + return authoritative; + } catch (cause) { + if (signal?.aborted) return null; + if (surfaceError) { + setPollError(cause instanceof Error ? cause.message : String(cause)); + } + return null; + } + } + + async function reconcileTaskList(signal?: AbortSignal) { + try { + const authoritative = await listMigrationTasks(signal); + if (signal?.aborted) return; + setTasks(authoritative); + setPollError(""); + } catch (cause) { + if (signal?.aborted) return; + setPollError(cause instanceof Error ? cause.message : String(cause)); + } + } + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void Promise.all([ + getMigrationCapabilities(controller.signal), + listMigrationTasks(controller.signal), + ]) + .then(([nextCapability, nextTasks]) => { + if (controller.signal.aborted) return; + setCapability(nextCapability); + setTasks(nextTasks); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, []); + + useEffect( + () => () => { + transferAbortRef.current?.abort(); + transferAbortRef.current = null; + }, + [], + ); + + useEffect(() => { + const timer = window.setInterval(() => { + const currentNow = Date.now(); + setNow(currentNow); + setTasks((current) => expireTasksAtDeadline(current, currentNow)); + }, 1_000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!tasks.some((item) => isActiveState(item.state))) return; + const controller = new AbortController(); + const timer = window.setInterval(() => { + void listMigrationTasks(controller.signal) + .then((nextTasks) => { + if (!controller.signal.aborted) setTasks(nextTasks); + }) + .catch((cause: unknown) => { + if (controller.signal.aborted) return; + setPollError(cause instanceof Error ? cause.message : String(cause)); + if (!(cause instanceof MigrationApiError && cause.retryable)) { + window.clearInterval(timer); + } + }); + }, LIST_POLL_INTERVAL_MS); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [tasks.some((item) => isActiveState(item.state))]); + + useEffect(() => { + if (!task || !isActiveState(task.state)) return; + const controller = new AbortController(); + let timer: number | undefined; + const poll = async () => { + try { + const next = await getMigrationTask(task.id, controller.signal); + if (controller.signal.aborted) return; + setTasks((current) => upsertTask(current, next)); + setPollError(""); + if (isActiveState(next.state)) { + timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); + } + } catch (cause) { + if (controller.signal.aborted) return; + setPollError(cause instanceof Error ? cause.message : String(cause)); + if (cause instanceof MigrationApiError && cause.retryable) { + timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); + } + } + }; + timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); + return () => { + controller.abort(); + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [task?.id, task?.state]); + + useEffect(() => { + if ( + !task?.analysis || + task.state !== "analysis_ready" || + confirmationTaskRef.current === task.id + ) { + return; + } + confirmationTaskRef.current = task.id; + const recommended = task.analysis.recommended; + setFramework(recommended.framework); + setEntry(recommended.entry || ""); + setAppName(defaultAppName(task.sourceFileName)); + setAdditionalInstruction(""); + setAnswers({}); + }, [task]); + + useEffect(() => { + setArtifact(null); + setArtifactError(""); + setArtifactErrorRetryable(false); + setDeploymentOpen(false); + if (!task?.artifact.previewReady) return; + const controller = new AbortController(); + void getMigrationArtifact(task.id, controller.signal) + .then((next) => { + if (!controller.signal.aborted) setArtifact(next); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setArtifactError( + cause instanceof Error ? cause.message : String(cause), + ); + setArtifactErrorRetryable( + cause instanceof MigrationApiError && cause.retryable, + ); + } + }); + return () => controller.abort(); + }, [task?.id, task?.artifact.previewReady, artifactReload]); + + function selectFile(file: File | undefined) { + if (transferAbortRef.current) return; + setError(""); + if (!file) return; + if (!file.name.toLowerCase().endsWith(".zip")) { + setSourceFile(null); + setError("请选择 .zip 格式的本地项目文件。"); + return; + } + if ( + file.name.length > 255 || + /[/\\\u0000-\u001f]/.test(file.name) + ) { + setSourceFile(null); + setError("ZIP 文件名无效,请重命名后重新选择。"); + return; + } + if (file.size > MAX_SOURCE_BYTES) { + setSourceFile(null); + setError("项目 ZIP 不能超过 50 MiB。"); + return; + } + if (file.size === 0) { + setSourceFile(null); + setError("项目 ZIP 不能为空。"); + return; + } + setSourceFile(file); + } + + function handleFileChange(event: ChangeEvent) { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + selectFile(file); + } + + async function createAndUpload() { + if (!sourceFile || action || transferAbortRef.current) return; + const controller = new AbortController(); + transferAbortRef.current = controller; + const isCurrent = () => + transferAbortRef.current === controller && !controller.signal.aborted; + const createdTaskId = `migration-v1-${crypto.randomUUID().replace(/-/g, "")}`; + setAction("create"); + setError(""); + try { + const created = await createMigrationTask({ + taskId: createdTaskId, + sourceFileName: sourceFile.name, + instruction: instruction.trim(), + signal: controller.signal, + }); + if (!isCurrent()) return; + setTasks((current) => upsertTask(current, created)); + setSelectedTaskId(created.id); + setAction("upload"); + const uploaded = await uploadMigrationSource( + created.id, + sourceFile, + controller.signal, + ); + if (!isCurrent()) return; + setTasks((current) => upsertTask(current, uploaded)); + setSourceFile(null); + setInstruction(""); + } catch (cause) { + if (!isCurrent()) return; + const authoritative = await reconcileTaskState( + createdTaskId, + false, + controller.signal, + ); + if (!isCurrent()) return; + if (authoritative) { + setSelectedTaskId(authoritative.id); + if (authoritative.state !== "awaiting_upload") { + setSourceFile(null); + setInstruction(""); + return; + } + } else { + await reconcileTaskList(controller.signal); + if (!isCurrent()) return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (transferAbortRef.current === controller) { + transferAbortRef.current = null; + setAction(""); + } + } + } + + async function uploadExistingTask() { + if (!task?.canUpload || !sourceFile || action || transferAbortRef.current) { + return; + } + const controller = new AbortController(); + transferAbortRef.current = controller; + const isCurrent = () => + transferAbortRef.current === controller && !controller.signal.aborted; + setAction("upload"); + setError(""); + try { + const uploaded = await uploadMigrationSource( + task.id, + sourceFile, + controller.signal, + ); + if (!isCurrent()) return; + setTasks((current) => upsertTask(current, uploaded)); + setSourceFile(null); + } catch (cause) { + if (!isCurrent()) return; + const authoritative = await reconcileTaskState( + task.id, + true, + controller.signal, + ); + if (!isCurrent()) return; + if (authoritative && authoritative.state !== "awaiting_upload") { + setSourceFile(null); + return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (transferAbortRef.current === controller) { + transferAbortRef.current = null; + setAction(""); + } + } + } + + const entryOptions = useMemo( + () => + (task?.analysis?.entries ?? []) + .filter((candidate) => candidate.framework === framework) + .map((candidate) => ({ + value: candidate.value, + label: candidate.value, + description: candidate.evidence, + })), + [framework, task?.analysis?.entries], + ); + const requiredQuestionsAnswered = (task?.analysis?.questions ?? []).every( + (question) => !question.required || Boolean(answers[question.id]?.trim()), + ); + const confirmationNameError = appNameError(appName); + const canConfirm = Boolean( + task?.canConfirm && + !action && + !confirmationNameError && + requiredQuestionsAnswered && + (!STRUCTURED_FRAMEWORKS.has(framework) || entry.trim()), + ); + + async function confirmMigration() { + if (!task || !canConfirm) return; + setAction("confirm"); + setError(""); + try { + const next = await confirmMigrationTask({ + taskId: task.id, + framework, + entry: STRUCTURED_FRAMEWORKS.has(framework) ? entry.trim() : undefined, + appName: appName.trim(), + instruction: additionalInstruction.trim(), + answers, + }); + setTasks((current) => upsertTask(current, next)); + } catch (cause) { + const authoritative = await reconcileTaskState(task.id); + if (authoritative && authoritative.state !== "analysis_ready") return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(""); + } + } + + async function stopTask() { + if (!task?.canStop || action) return; + setAction("stop"); + setError(""); + try { + const next = await stopMigrationTask(task.id); + setTasks((current) => upsertTask(current, next)); + setStopConfirmOpen(false); + } catch (cause) { + const authoritative = await reconcileTaskState(task.id); + if (authoritative && !isActiveState(authoritative.state)) { + setStopConfirmOpen(false); + return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(""); + } + } + + async function downloadArtifact() { + if (!task?.artifact.downloadReady || action) return; + setAction("download"); + setError(""); + try { + await downloadMigrationArtifact(task.id, sourceStem(task.sourceFileName)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(""); + } + } + + function startNewMigration() { + setSelectedTaskId(""); + setSourceFile(null); + setInstruction(""); + setError(""); + setPollError(""); + setArtifact(null); + setArtifactError(""); + setArtifactErrorRetryable(false); + setDeploymentOpen(false); + setStopConfirmOpen(false); + } + + const deploymentProject: AgentProject | null = artifact + ? { + name: + task?.confirmation?.app_name || + defaultAppName(task?.sourceFileName || "migration.zip"), + files: [ + { + path: "migration-result.json", + content: `${JSON.stringify(artifact, null, 2)}\n`, + }, + ], + } + : null; + const deploymentEnv: EnvVar[] = artifact + ? [ + ...artifact.environment.required + .filter((key) => !key.startsWith("MODEL_AGENT_")) + .map((key) => ({ + key, + required: true, + comment: key, + placeholder: `请输入 ${key}`, + })), + ...artifact.environment.optional.map((key) => ({ + key, + required: false, + comment: key, + placeholder: `可选:${key}`, + })), + ] + : []; + + async function handleDeploy( + project: AgentProject, + onStage?: (stage: DeployStage) => void, + options?: Parameters[3], + ) { + if (!task || !artifact) throw new Error("迁移产物尚未准备完成。"); + const runtimeNetwork = + network && network.mode !== "public" + ? { + mode: network.mode, + vpc_id: network.vpcId, + subnet_ids: network.subnetIds, + enable_shared_internet_access: network.enableSharedInternetAccess, + } + : undefined; + return deployAgentkitProject( + project.name, + project.files, + { + region: deployRegion, + projectName: "default", + network: runtimeNetwork, + }, + { + ...options, + migrationTaskId: task.id, + onStage, + }, + ); + } + + if (deploymentOpen && deploymentProject && task && artifact) { + return ( +
+ + setDeploymentEnvValues((current) => ({ ...current, [key]: value })) + } + deploymentTelemetry={{ + source: "migration", + createMode: "migration", + aiAssisted: true, + }} + onBack={() => setDeploymentOpen(false)} + backLabel="返回迁移结果" + deploymentPrimaryPane={ +
+ 迁移产物 + {task.sourceFileName} +
+
+
迁移方式
+
{artifact.migration.framework}
+
+
+
启动文件
+
{artifact.startup.module}
+
+
+
文件数
+
{artifact.files.length}
+
+
+
+ } + /> +
+ ); + } + + const composerFile = sourceFile; + const composerBusy = action === "create" || action === "upload"; + const showComposer = !task || task.canUpload; + + return ( + <> +
+ + +
+
+
+

+ {task ? sourceStem(task.sourceFileName) : "迁移存量 Agent 项目"} +

+

+ {task + ? task.message + : "上传本地项目 ZIP,Codex 将先进行只读分析,再由你确认迁移方式。"} +

+
+ {task ? ( + {remainingLabel(task, now)} + ) : null} +
+ +
+ {!capability?.enabled && !loading ? ( +
+ 迁移能力暂不可用 +

{capability?.reason || "Dev Sandbox 暂不可用,请联系管理员检查配置。"}

+
+ ) : null} + + {!task ? ( +
+
AI
+
+

+ 请提供本地 ZIP 和迁移目标。项目上传后,我会先识别框架、入口和迁移边界, + 不会在你确认前执行迁移。 +

+ 仅支持本地 ZIP,最大 50 MiB;Session 从创建起保留 1 小时。 +
+
+ ) : ( + <> +
+
+ + + {task.sourceFileName} + + {task.instruction ?

{task.instruction}

: null} +
+
+ +
+
AI
+
+ {isActiveState(task.state) ? ( + <> + {task.message} +

+ 迁移执行中不能修改附件或迁移方式。你可以等待当前任务结束,或主动终止。 +

+ + ) : task.state === "analysis_ready" && task.analysis ? ( + <> +

只读分析已完成。请检查建议,并确认最终迁移方式。

+ + + ) : task.state === "awaiting_upload" ? ( +

Session 已创建,请重新选择本地 ZIP 继续上传。

+ ) : task.state === "expired" ? ( +
+ Dev Sandbox 已超过 1 小时 TTL +

+ 迁移内容和产物已无法预览、下载或部署。如已完成 Runtime 部署,可返回智能体页面继续使用。 +

+
+ ) : task.state === "failed" ? ( +
+ 迁移未完成 +

{task.error?.message || task.message}

+
+ ) : task.state === "cancelled" ? ( +

当前迁移已终止。你可以新建迁移并重新上传项目。

+ ) : ( +

{task.message}

+ )} +
+
+ + )} + + {task?.state === "analysis_ready" && task.analysis ? ( +
+
+ 确认迁移方式 + 确认后才会执行实际迁移 +
+
+ ({ + value: item, + label: FRAMEWORK_LABELS[item], + }))} + onChange={(value) => { + const next = value as MigrationFramework; + setFramework(next); + const candidate = task.analysis?.entries.find( + (item) => item.framework === next, + ); + setEntry(candidate?.value || ""); + }} + placeholder="选择迁移框架" + disabled={Boolean(action)} + /> + + {STRUCTURED_FRAMEWORKS.has(framework) ? ( + entryOptions.length > 0 ? ( + + ) : ( + + ) + ) : null} +
+ {task.analysis.questions.map((question) => ( +