diff --git a/frontend/server/intelligent_development.py b/frontend/server/intelligent_development.py index bf24dc8bd..2ca853301 100644 --- a/frontend/server/intelligent_development.py +++ b/frontend/server/intelligent_development.py @@ -116,7 +116,8 @@ def as_dict(self) -> dict[str, object]: MAX_BYTES = 20 * 1024 * 1024 MAX_FILES = 2000 ZIP_TIME = (1980, 1, 1, 0, 0, 0) -EXCLUDED_NAMES = {".git", ".agentkit", "node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".DS_Store", "dist", "target"} +EXCLUDED_NAMES = {".git", "node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".DS_Store", "dist", "target"} +EXCLUDED_PATHS = {".agentkit/artifacts", ".agentkit/migrate"} FORBIDDEN_DIRECTORIES = {".aws", ".ssh", ".kube"} FORBIDDEN_FILE_NAMES = {"id_rsa", "id_ed25519"} FORBIDDEN_SUFFIXES = {".key", ".pem", ".crt", ".secret", ".p12", ".pfx"} @@ -184,18 +185,19 @@ def collect_project(path, secrets): kept = [] for name in sorted(directories): candidate = current_path / name + relative = relative_root / name metadata = os.lstat(candidate) if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): fail("Delivery source contains an unsafe entry") if name.lower() in FORBIDDEN_DIRECTORIES: fail("Delivery source contains a forbidden credential directory") - if name not in EXCLUDED_NAMES: + if name not in EXCLUDED_NAMES and relative.as_posix() not in EXCLUDED_PATHS: kept.append(name) directories[:] = kept for name in sorted(names): candidate = current_path / name relative = relative_root / name - if name in EXCLUDED_NAMES or name.startswith(COMPLETION_PREFIXES): + if name in EXCLUDED_NAMES or relative.as_posix() in EXCLUDED_PATHS or name.startswith(COMPLETION_PREFIXES): continue lower_name = name.lower() if lower_name == ".env" or (lower_name.startswith(".env.") and lower_name != ".env.example"): diff --git a/frontend/server/migration/service.py b/frontend/server/migration/service.py index f70d0d5df..766f27e91 100644 --- a/frontend/server/migration/service.py +++ b/frontend/server/migration/service.py @@ -2772,15 +2772,6 @@ def _task_payload( request = request or {} expiry = self._session_expiry(session, request) artifact_status = self._artifact_status(artifact) - if ( - state in {"succeeded", "succeeded_with_warnings", "partial"} - and artifact_status["previewReady"] - and artifact_status["downloadReady"] - ): - # CLI deploy_ready reflects migration validation. Studio can still - # deploy an integrity-checked artifact and surface repairable issues - # while Runtime deployment performs the authoritative build check. - artifact_status["deployReady"] = True payload: dict[str, object] = { "id": session.task_id, "state": state, @@ -3825,14 +3816,14 @@ def materialize_deployment( if ( task.get("state") not in {"succeeded", "succeeded_with_warnings", "partial"} or not isinstance(artifact_status, dict) - or not artifact_status.get("downloadReady") + or not artifact_status.get("deployReady") ): raise MigrationError( "MIGRATION_ARTIFACT_NOT_DEPLOYABLE", - "迁移产物尚未完整交付,无法部署到 Runtime。", + "尚未确认迁移产物可部署到 Runtime。", status_code=409, ) - result = self._artifact_result(session, task, readiness="downloadReady") + result = self._artifact_result(session, task, readiness="deployReady") content = self._verified_artifact_content(session, result) try: return extract_migration_source(target, content, result) diff --git a/tests/frontend/server/test_intelligent_development_task.py b/tests/frontend/server/test_intelligent_development_task.py index 1eb4147d8..4d2cf99ce 100644 --- a/tests/frontend/server/test_intelligent_development_task.py +++ b/tests/frontend/server/test_intelligent_development_task.py @@ -995,7 +995,10 @@ def test_delivery_worker_packages_final_project_and_excludes_local_state( ), "weather.py": b"root_agent = object()\n", ".env.example": b"MODEL_API_KEY=replace-me\n", + ".agentkit/agentkit.yaml": b"name: weather\n", + ".agentkit/Dockerfile": b"FROM python:3.12-slim\n", ".agentkit/artifacts/build.log": b"cloud build evidence\n", + ".agentkit/migrate/session.json": b"{}\n", ".studio-intelligent-development-result.json": b"{}", }, ) @@ -1004,6 +1007,8 @@ def test_delivery_worker_packages_final_project_and_excludes_local_state( artifact = Path(descriptor["artifactPath"]) with zipfile.ZipFile(artifact) as archive: assert sorted(archive.namelist()) == [ + ".agentkit/Dockerfile", + ".agentkit/agentkit.yaml", ".env.example", "agentkit.yaml", "weather.py", @@ -1018,6 +1023,7 @@ def test_delivery_worker_packages_migrated_project_without_root_manifest( files={ "main.py": b"root_agent = object()\n", ".agentkit/agentkit.yaml": b"name: legacy\n", + ".agentkit/Dockerfile": b"FROM python:3.12-slim\n", }, trusted_metadata=("travel_planner", "main.py"), ) @@ -1027,7 +1033,11 @@ def test_delivery_worker_packages_migrated_project_without_root_manifest( assert descriptor["agentName"] == "travel_planner" assert descriptor["entryPoint"] == "main.py" with zipfile.ZipFile(descriptor["artifactPath"]) as archive: - assert archive.namelist() == ["main.py"] + assert sorted(archive.namelist()) == [ + ".agentkit/Dockerfile", + ".agentkit/agentkit.yaml", + "main.py", + ] def test_delivery_worker_uses_trusted_entry_point_when_manifest_target_is_absent( diff --git a/tests/frontend/test_migration_server.py b/tests/frontend/test_migration_server.py index 02d25c83b..2c06a292d 100644 --- a/tests/frontend/test_migration_server.py +++ b/tests/frontend/test_migration_server.py @@ -3433,7 +3433,7 @@ def test_service_recovers_terminal_state_and_verified_artifact_from_session() -> @pytest.mark.parametrize( - ("delivery_state", "verification"), + ("delivery_state", "verification", "deploy_ready"), [ ( "succeeded", @@ -3441,6 +3441,7 @@ def test_service_recovers_terminal_state_and_verified_artifact_from_session() -> "status": "passed", "checks": [{"name": "import", "status": "passed"}], }, + True, ), ( "partial", @@ -3454,13 +3455,15 @@ def test_service_recovers_terminal_state_and_verified_artifact_from_session() -> } ], }, + False, ), ], ) -def test_materialize_deployment_accepts_complete_artifact_and_verifies_owner( +def test_materialize_deployment_honors_cli_readiness_and_verifies_owner( tmp_path: Path, delivery_state: str, verification: dict[str, object], + deploy_ready: bool, ) -> None: gateway = FakeMigrationGateway() service = MigrationService(gateway) @@ -3535,7 +3538,7 @@ def test_materialize_deployment_accepts_complete_artifact_and_verifies_owner( "state": "ready", "preview_ready": True, "download_ready": True, - "deploy_ready": False, + "deploy_ready": deploy_ready, }, "updated_at": "2026-08-11T08:20:00Z", } @@ -3545,6 +3548,18 @@ def test_materialize_deployment_accepts_complete_artifact_and_verifies_owner( with pytest.raises(MigrationError) as wrong_owner: service.materialize_deployment(task_id, "owner-2", tmp_path) + assert task["artifact"]["deployReady"] is deploy_ready + assert wrong_owner.value.status_code == 404 + if not deploy_ready: + downloaded, _ = service.download(task_id, "owner-1") + with pytest.raises(MigrationError) as not_deployable: + service.materialize_deployment(task_id, "owner-1", tmp_path) + + assert downloaded == artifact + assert not_deployable.value.code == "MIGRATION_ARTIFACT_NOT_DEPLOYABLE" + assert str(not_deployable.value) == "尚未确认迁移产物可部署到 Runtime。" + return + target = tmp_path / "deploy" target.mkdir() entry_point = service.materialize_deployment( @@ -3553,8 +3568,6 @@ def test_materialize_deployment_accepts_complete_artifact_and_verifies_owner( target, ) - assert task["artifact"]["deployReady"] is True - assert wrong_owner.value.status_code == 404 assert entry_point == "runtime/agentkit_app.py" assert (target / entry_point).read_bytes() == project_files[entry_point] assert (target / "agentkit.yaml").read_bytes() == project_files["agentkit.yaml"] @@ -3886,6 +3899,51 @@ def test_terminal_delivery_requires_a_ready_artifact_contract() -> None: assert raised.value.retryable is False +@pytest.mark.parametrize( + "state", + ["succeeded", "succeeded_with_warnings", "partial"], +) +def test_terminal_delivery_preserves_cli_deployment_readiness(state: str) -> None: + gateway = FakeMigrationGateway() + service = MigrationService(gateway) + task_id, _ = create_uploaded_task(service) + mark_analysis_ready(gateway, task_id) + service.confirm( + task_id, + "owner-1", + confirmation_body(gateway, task_id), + ) + gateway.files[(task_id, f"{MIGRATION_ROOT}/delivery/migration-status.json")] = ( + json.dumps( + { + "schema_version": 1, + "run_id": task_id, + "sequence": 4, + "state": state, + "phase": "completed", + "message": "Migration artifact is ready", + "artifact": { + "state": "ready", + "preview_ready": True, + "download_ready": True, + "deploy_ready": False, + }, + "updated_at": "2026-08-11T08:20:00Z", + } + ).encode() + ) + + task = service.get_task(task_id, "owner-1") + + assert task["state"] == state + assert task["artifact"] == { + "state": "ready", + "previewReady": True, + "downloadReady": True, + "deployReady": False, + } + + def test_partial_delivery_cannot_advertise_deployment_readiness() -> None: gateway = FakeMigrationGateway() service = MigrationService(gateway)