diff --git a/frontend/server/deployment_source.py b/frontend/server/deployment_source.py index c37667a28..9e94628a2 100644 --- a/frontend/server/deployment_source.py +++ b/frontend/server/deployment_source.py @@ -49,8 +49,16 @@ class DeploymentSourceError(ValueError): """Deployment source does not satisfy the trusted package contract.""" -def ensure_default_agentkit_dockerfile(base: Path, cloud_provider: str) -> bool: - """Add Studio's canonical Dockerfile when a deployment has no custom one.""" +def ensure_default_agentkit_dockerfile( + base: Path, + cloud_provider: str, + *, + entry_point: str | None = None, +) -> bool: + """Add Studio's canonical Dockerfile when a deployment has no custom one. + + When provided, ``entry_point`` is used as the Python startup script. + """ dockerfile = base / "Dockerfile" if dockerfile.exists(): @@ -59,7 +67,10 @@ def ensure_default_agentkit_dockerfile(base: Path, cloud_provider: str) -> bool: from veadk.cli.generated_agent_codegen import render_default_agentkit_dockerfile dockerfile.write_text( - render_default_agentkit_dockerfile(cloud_provider), + render_default_agentkit_dockerfile( + cloud_provider, + entry_point=entry_point, + ), encoding="utf-8", ) return True @@ -102,26 +113,26 @@ def _is_macos_metadata(relative: str) -> bool: ) -def _configured_entry_point(base: Path) -> str: +def _configured_entry_point(base: Path, *, fallback: str) -> str: manifest_path = base / "agentkit.yaml" if not manifest_path.is_file(): - return _DEFAULT_ENTRY_POINT + return fallback 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 + return fallback if not isinstance(manifest, Mapping): raise DeploymentSourceError("agentkit.yaml 根节点必须是对象。") common = manifest.get("common") if common is None: - return _DEFAULT_ENTRY_POINT + return fallback if not isinstance(common, Mapping): raise DeploymentSourceError("agentkit.yaml 的 common 必须是对象。") value = common.get("entry_point") if value is None: - return _DEFAULT_ENTRY_POINT + return fallback return _relative_path(value, field="agentkit.yaml common.entry_point") @@ -132,6 +143,20 @@ def _require_entry_point(base: Path, entry_point: str) -> str: return entry_point +def resolve_agentkit_entry_point( + base: Path, + *, + fallback: str = _DEFAULT_ENTRY_POINT, +) -> str: + """Resolve and validate the root AgentKit manifest's Python entry point.""" + + safe_fallback = _relative_path(fallback, field="部署入口文件") + return _require_entry_point( + base, + _configured_entry_point(base, fallback=safe_fallback), + ) + + def _reject_path_collisions(paths: set[str]) -> None: for relative in paths: path = PurePosixPath(relative) @@ -211,7 +236,7 @@ def write_inline_source(base: Path, files: object) -> str: 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)) + return resolve_agentkit_entry_point(base) def _manifest_files(manifest: object) -> tuple[dict[str, tuple[int, str]], str]: @@ -306,5 +331,6 @@ def extract_migration_source( __all__ = [ "DeploymentSourceError", "extract_migration_source", + "resolve_agentkit_entry_point", "write_inline_source", ] diff --git a/frontend/server/intelligent_development_source.py b/frontend/server/intelligent_development_source.py index 0439112f9..b2d4b0d22 100644 --- a/frontend/server/intelligent_development_source.py +++ b/frontend/server/intelligent_development_source.py @@ -35,6 +35,7 @@ IntelligentDevelopmentVersion, IntelligentDevelopmentVersionIntegrityError, IntelligentDevelopmentVersionNotFound, + SourceVersionProducer, ) from frontend.server.sandbox_remote import SandboxRemoteTransport from frontend.server.source_project_limits import ( @@ -107,6 +108,7 @@ class TrustedDeploymentSource: environment_required: tuple[str, ...] = () environment_optional: tuple[str, ...] = () environment_defaults: tuple[tuple[str, str], ...] = () + producer: SourceVersionProducer = "intelligent-development" @dataclass(frozen=True) @@ -362,6 +364,7 @@ async def _materialize_intelligent_development_source( tuple(stored_metadata.environment.required), tuple(stored_metadata.environment.optional), tuple(sorted(stored_metadata.environment.defaults.items())), + producer=stored_metadata.producer, ), artifact, ) @@ -466,6 +469,11 @@ async def _materialize_intelligent_development_source( source_files, project_id if isinstance(project_id, str) else "", version_id if isinstance(version_id, str) else "", + producer=( + stored_metadata.producer + if stored_metadata is not None + else "intelligent-development" + ), ), artifact, ) diff --git a/frontend/tests/migrationWorkspace.test.mjs b/frontend/tests/migrationWorkspace.test.mjs index a912c6b07..6d26142ec 100644 --- a/frontend/tests/migrationWorkspace.test.mjs +++ b/frontend/tests/migrationWorkspace.test.mjs @@ -105,6 +105,11 @@ test("keeps new migration and migrated projects as parallel workspace pages", () assert.match(projects, /onCreate=\{onOptimize\}/); assert.match(projects, /onDownload=\{onDownload\}/); assert.match(projects, /onDeploy=\{onDeploy\}/); + assert.match(source, /migrationTaskId: task\.id/); + assert.match( + appSource, + /onDeploySavedVersion=\{\(delivery\) => \{[\s\S]*?setIntelligentDeployment\(delivery\)/, + ); assert.match( appSource, /initialPage=\{migrationProjectReturn \? "projects" : "new"\}/, diff --git a/reference/actb-mono b/reference/actb-mono deleted file mode 160000 index 7d6536a8a..000000000 --- a/reference/actb-mono +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7d6536a8a2dda8c6f08ea267383e6020766748a9 diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py index d0537ac9a..51ce7492d 100644 --- a/tests/cli/test_studio_rbac.py +++ b/tests/cli/test_studio_rbac.py @@ -1402,10 +1402,13 @@ def test_code_package_manifest_entry_point_reaches_agentkit_sdk( tmp_path: Path, ) -> None: captured_config: dict[str, Any] = {} + captured_dockerfile = "" def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: + nonlocal captured_dockerfile config_path = Path(config_file) captured_config.update(yaml.safe_load(config_path.read_text())) + captured_dockerfile = (config_path.parent / "Dockerfile").read_text() assert (config_path.parent / "runtime" / "main.py").read_text() == ( "app = object()\n" ) @@ -1462,6 +1465,7 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: assert response.status_code == 200 assert frames[-1]["success"] is True assert captured_config["common"]["entry_point"] == "runtime/main.py" + assert 'CMD ["python", "-m", "app"]' in captured_dockerfile def test_migration_deployment_materializes_owned_session_source_server_side( @@ -1472,6 +1476,7 @@ def test_migration_deployment_materializes_owned_session_source_server_side( from veadk.config import veadk_environments captured_config: dict[str, Any] = {} + captured_dockerfile = "" materialized: dict[str, str] = {} def materialize( @@ -1481,15 +1486,42 @@ def materialize( target: Path, ) -> str: materialized.update(task_id=task_id, owner_id=owner_id) - entry = target / "runtime" / "migrated.py" - entry.parent.mkdir(parents=True) - entry.write_text("app = object()\n", encoding="utf-8") + configured_entry = target / "bailian-test-workflow-agent.py" + configured_entry.write_text("app = object()\n", encoding="utf-8") + startup_entry = target / "runtime" / "migrated.py" + startup_entry.parent.mkdir(parents=True) + startup_entry.write_text("app = object()\n", encoding="utf-8") + (target / "agentkit.yaml").write_text( + "common:\n" + " agent_name: bailian-test-workflow-agent\n" + " entry_point: bailian-test-workflow-agent.py\n" + " description: AgentKit project bailian-test-workflow-agent - Agent Server App\n" + " language: Python\n" + ' language_version: "3.12"\n' + " agent_type: WebServer App\n" + " dependencies_file: requirements.txt\n" + " launch_type: cloud\n", + encoding="utf-8", + ) + nested_dockerfile = target / ".agentkit" / "Dockerfile" + nested_dockerfile.parent.mkdir() + nested_dockerfile.write_text( + 'FROM example.com/nested:latest\nCMD ["python", "wrong.py"]\n', + encoding="utf-8", + ) return "runtime/migrated.py" def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: + nonlocal captured_dockerfile config_path = Path(config_file) captured_config.update(yaml.safe_load(config_path.read_text())) + captured_dockerfile = (config_path.parent / "Dockerfile").read_text() + assert (config_path.parent / "bailian-test-workflow-agent.py").is_file() assert (config_path.parent / "runtime" / "migrated.py").is_file() + assert ( + 'CMD ["python", "wrong.py"]' + in (config_path.parent / ".agentkit" / "Dockerfile").read_text() + ) assert not (config_path.parent / "browser.py").exists() return SimpleNamespace( success=True, @@ -1560,7 +1592,8 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: "task_id": "migration-v1-" + "1" * 32, "owner_id": "developer", } - assert captured_config["common"]["entry_point"] == "runtime/migrated.py" + assert captured_config["common"]["entry_point"] == "bailian-test-workflow-agent.py" + assert 'CMD ["python", "bailian-test-workflow-agent.py"]' in captured_dockerfile runtime_envs = captured_config["launch_types"]["cloud"]["runtime_envs"] assert runtime_envs["MODEL_AGENT_NAME"] == "doubao-seed-2-1-pro-260628" assert runtime_envs["MODEL_NAME"] == "doubao-seed-2-1-pro-260628" @@ -1569,6 +1602,135 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: ) +@pytest.mark.parametrize( + ("producer", "expected_command"), + [ + ( + "migration", + 'CMD ["python", "bailian-test-workflow-agent.py"]', + ), + ( + "intelligent-development", + 'CMD ["python", "-m", "app"]', + ), + ], +) +def test_saved_project_deployment_scopes_manifest_entry_point_to_migrations( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + producer: str, + expected_command: str, +) -> None: + from veadk.config import veadk_environments + + captured_config: dict[str, Any] = {} + captured_dockerfile = "" + materialized: dict[str, str] = {} + + async def materialize( + target: Path, + source: dict[str, str], + *, + owner_id: str, + **_kwargs: Any, + ) -> SimpleNamespace: + materialized.update( + kind=source["kind"], + project_id=source["projectId"], + version_id=source["versionId"], + owner_id=owner_id, + ) + configured_entry = target / "bailian-test-workflow-agent.py" + configured_entry.write_text("app = object()\n", encoding="utf-8") + startup_entry = target / "runtime" / "migrated.py" + startup_entry.parent.mkdir() + startup_entry.write_text("app = object()\n", encoding="utf-8") + (target / "agentkit.yaml").write_text( + "common:\n" + " agent_name: bailian-test-workflow-agent\n" + " entry_point: bailian-test-workflow-agent.py\n", + encoding="utf-8", + ) + return SimpleNamespace( + agent_name="bailian-test-workflow-agent", + entry_point="runtime/migrated.py", + producer=producer, + ) + + def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace: + nonlocal captured_dockerfile + config_path = Path(config_file) + captured_config.update(yaml.safe_load(config_path.read_text())) + captured_dockerfile = (config_path.parent / "Dockerfile").read_text() + assert (config_path.parent / "bailian-test-workflow-agent.py").is_file() + return SimpleNamespace( + success=True, + error=None, + deploy_result=SimpleNamespace( + endpoint_url="https://runtime.example.com", + metadata={ + "runtime_id": "runtime-saved-migration", + "runtime_name": "saved-migration-agent", + "runtime_endpoint": "https://runtime.example.com", + "runtime_apikey": "secret", + }, + ), + ) + + monkeypatch.setattr( + "frontend.server.intelligent_development_source." + "materialize_intelligent_development_source", + materialize, + ) + monkeypatch.setattr("agentkit.toolkit.sdk.launch", launch) + monkeypatch.setitem(veadk_environments, "MODEL_AGENT_API_KEY", "test-model-key") + app = _create_studio_app(monkeypatch, tmp_path, developers="developer") + + with ( + TestClient(app) as client, + client.stream( + "POST", + "/web/deploy-agentkit", + headers={"X-VeADK-Local-User": "developer"}, + json={ + "name": "saved-migration-agent", + "files": [], + "source": { + "kind": "intelligentDevelopment", + "sessionId": "session-saved-migration", + "projectId": "project-saved-migration", + "versionId": "version-saved-migration", + "artifactSha256": "a" * 64, + "validationReportSha256": "b" * 64, + }, + "config": {"region": "cn-beijing", "projectName": "default"}, + "createEvaluationSets": False, + }, + ) as response, + ): + frames = [ + json.loads(line.removeprefix("data: ")) + for line in response.iter_lines() + if line.startswith("data: ") + ] + + assert response.status_code == 200 + assert frames[-1]["success"] is True + assert materialized == { + "kind": "intelligentDevelopment", + "project_id": "project-saved-migration", + "version_id": "version-saved-migration", + "owner_id": "developer", + } + expected_entry_point = ( + "bailian-test-workflow-agent.py" + if producer == "migration" + else "runtime/migrated.py" + ) + assert captured_config["common"]["entry_point"] == expected_entry_point + assert expected_command in captured_dockerfile + + def test_migration_deployment_rejection_removes_temporary_source( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/frontend/server/test_intelligent_development_source.py b/tests/frontend/server/test_intelligent_development_source.py index 025de77a5..deae9b4ea 100644 --- a/tests/frontend/server/test_intelligent_development_source.py +++ b/tests/frontend/server/test_intelligent_development_source.py @@ -475,6 +475,111 @@ async def test_materializes_stored_version_without_live_sandbox( assert FakeTransport.instances == [] +@pytest.mark.asyncio +async def test_materialized_stored_migration_retains_its_producer( + tmp_path: Path, +) -> None: + configured_entry = "bailian-test-workflow-agent.py" + startup_entry = "runtime/migrated.py" + artifact = _zip( + [ + ( + "agentkit.yaml", + ( + "common:\n" + " agent_name: bailian-test-workflow-agent\n" + f" entry_point: {configured_entry}\n" + ).encode(), + ), + (configured_entry, b"app = object()\n"), + (startup_entry, b"app = object()\n"), + ] + ) + artifact_digest = _digest(artifact) + result_value = { + "schema_version": 1, + "run_id": SESSION_ID, + "cli": {"name": "agentkit-cli", "version": "0.52.1"}, + "status": "succeeded", + "created_at": "2026-08-26T00:00:00Z", + "migration": { + "framework": "any", + "engine": "agentic", + "source_sha256": "2" * 64, + "provenance_sha256": "3" * 64, + }, + "startup": {"module": startup_entry, "object": "app"}, + "environment": {"required": [], "optional": []}, + "verification": {"status": "passed", "checks": []}, + "warnings": [], + "report": {"path": "agentkit.yaml"}, + "artifact": { + "path": "migration-result.zip", + "sha256": artifact_digest, + "size": len(artifact), + }, + "files": [], + } + with zipfile.ZipFile(io.BytesIO(artifact)) as archive: + result_value["files"] = [ + { + "path": info.filename, + "size": info.file_size, + "sha256": _digest(archive.read(info)), + "mode": "0644", + } + for info in archive.infolist() + ] + report = _json_bytes(result_value) + report_digest = _digest(report) + version = IntelligentDevelopmentVersion( + producer="migration", + projectId="a" * 32, + versionId="b" * 32, + sourceSessionId=SESSION_ID, + createdAt=datetime(2026, 8, 26, tzinfo=timezone.utc), + intentSummary="迁移 Agent", + acceptanceCriteria=[], + artifactSha256=artifact_digest, + validationReportSha256=report_digest, + artifactSize=len(artifact), + fileCount=3, + agentName="bailian-test-workflow-agent", + entryPoint=startup_entry, + verified=True, + validationSummary="迁移校验通过", + gateSummary=[], + validatedAt="2026-08-26T00:00:00Z", + ) + project_service = cast( + IntelligentDevelopmentProjectService, + SimpleNamespace( + load_version=AsyncMock( + return_value=StoredDevelopmentVersion(version, artifact, report) + ) + ), + ) + + result = await materialize_intelligent_development_source( + tmp_path, + { + "kind": "intelligentDevelopment", + "sessionId": SESSION_ID, + "projectId": "a" * 32, + "versionId": "b" * 32, + "artifactSha256": artifact_digest, + REPORT_DIGEST_FIELD: report_digest, + }, + owner_id=OWNER_ID, + service=None, + project_service=project_service, + ) + + assert result.producer == "migration" + assert result.entry_point == startup_entry + assert (tmp_path / configured_entry).is_file() + + @pytest.mark.asyncio async def test_current_preview_distinguishes_no_delivery_and_validates_current_release( tmp_path: Path, diff --git a/tests/frontend/test_deployment_source.py b/tests/frontend/test_deployment_source.py index f59041a5b..b31ed88e0 100644 --- a/tests/frontend/test_deployment_source.py +++ b/tests/frontend/test_deployment_source.py @@ -27,6 +27,7 @@ DeploymentSourceError, ensure_default_agentkit_dockerfile, extract_migration_source, + resolve_agentkit_entry_point, write_inline_source, ) @@ -45,6 +46,7 @@ def test_default_agentkit_dockerfile_uses_volcengine_fallbacks( assert ensure_default_agentkit_dockerfile(tmp_path, "volcengine") is True dockerfile = (tmp_path / "Dockerfile").read_text(encoding="utf-8") + assert 'CMD ["python", "-m", "app"]' in dockerfile huawei = "https://repo.huaweicloud.com/repository/pypi/simple" aliyun = "https://mirrors.aliyun.com/pypi/simple/" pypi = "https://pypi.org/simple" @@ -55,10 +57,39 @@ def test_default_agentkit_dockerfile_preserves_custom_file(tmp_path: Path) -> No custom = "FROM example.com/custom:latest\n" (tmp_path / "Dockerfile").write_text(custom, encoding="utf-8") - assert ensure_default_agentkit_dockerfile(tmp_path, "volcengine") is False + assert ( + ensure_default_agentkit_dockerfile( + tmp_path, + "volcengine", + entry_point="agentkit_app.py", + ) + is False + ) assert (tmp_path / "Dockerfile").read_text(encoding="utf-8") == custom +def test_default_agentkit_dockerfile_uses_explicit_entry_point_and_ignores_nested_file( + tmp_path: Path, +) -> None: + nested_dockerfile = tmp_path / ".agentkit" / "Dockerfile" + nested_dockerfile.parent.mkdir() + nested_content = 'FROM example.com/nested:latest\nCMD ["python", "wrong.py"]\n' + nested_dockerfile.write_text(nested_content, encoding="utf-8") + + assert ( + ensure_default_agentkit_dockerfile( + tmp_path, + "volcengine", + entry_point="runtime/agentkit_app.py", + ) + is True + ) + + dockerfile = (tmp_path / "Dockerfile").read_text(encoding="utf-8") + assert 'CMD ["python", "runtime/agentkit_app.py"]' in dockerfile + assert nested_dockerfile.read_text(encoding="utf-8") == nested_content + + def test_default_agentkit_dockerfile_keeps_byteplus_default_index( tmp_path: Path, ) -> None: @@ -101,6 +132,22 @@ def test_inline_source_uses_manifest_entry_and_keeps_app_py_fallback( assert fallback == "app.py" +def test_agentkit_entry_point_falls_back_to_migration_startup( + tmp_path: Path, +) -> None: + entry = tmp_path / "runtime" / "migrated.py" + entry.parent.mkdir() + entry.write_text("app = object()\n", encoding="utf-8") + + assert ( + resolve_agentkit_entry_point( + tmp_path, + fallback="runtime/migrated.py", + ) + == "runtime/migrated.py" + ) + + @pytest.mark.parametrize( ("files", "message"), [ diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 815500e50..10636d05c 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -7249,6 +7249,7 @@ def _agentkit_sdk_credential_env(): from frontend.server.deployment_source import ( DeploymentSourceError, ensure_default_agentkit_dockerfile, + resolve_agentkit_entry_point, write_inline_source, ) from frontend.server.intelligent_development_source import ( @@ -7262,14 +7263,19 @@ def _agentkit_sdk_credential_env(): temp_dir = tempfile.mkdtemp(prefix=f"agentkit_deploy_{agent_name}_") base = PathlibPath(temp_dir).resolve() + migration_deployment_source = source.get("kind") == "migration" try: - if source.get("kind") == "migration": - entry_point = await asyncio.to_thread( + if migration_deployment_source: + migration_startup_entry_point = await asyncio.to_thread( migration_service.materialize_deployment, migration_task_id, owner_id or "local", base, ) + entry_point = resolve_agentkit_entry_point( + base, + fallback=migration_startup_entry_point, + ) trusted_agent_name = agent_name elif trusted_intelligent_source: from frontend.server.intelligent_development_source import ( @@ -7283,7 +7289,15 @@ def _agentkit_sdk_credential_env(): service=intelligent_development_service, project_service=intelligent_project_service, ) - entry_point = materialized.entry_point + migration_deployment_source = materialized.producer == "migration" + entry_point = ( + resolve_agentkit_entry_point( + base, + fallback=materialized.entry_point, + ) + if migration_deployment_source + else materialized.entry_point + ) trusted_agent_name = materialized.agent_name else: entry_point = write_inline_source(base, files) @@ -7324,7 +7338,11 @@ def _agentkit_sdk_credential_env(): raise if not use_managed_sidecar_release: - ensure_default_agentkit_dockerfile(base, provider) + ensure_default_agentkit_dockerfile( + base, + provider, + entry_point=entry_point if migration_deployment_source else None, + ) if use_managed_sidecar_release: try: diff --git a/veadk/cli/generated_agent_codegen.py b/veadk/cli/generated_agent_codegen.py index 119409c07..a45ef4be2 100644 --- a/veadk/cli/generated_agent_codegen.py +++ b/veadk/cli/generated_agent_codegen.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json import re from pprint import pformat from typing import Any, Literal @@ -2160,9 +2161,19 @@ def _render_python_dependency_install(cloud_provider: str) -> str: return "RUN " + " || \\\n ".join(attempts) -def render_default_agentkit_dockerfile(cloud_provider: str) -> str: +def render_default_agentkit_dockerfile( + cloud_provider: str, + *, + entry_point: str | None = None, +) -> str: """Render the canonical Dockerfile for ordinary Studio Agent deployments.""" + startup_command = ( + 'CMD ["python", "-m", "app"]' + if entry_point is None + else f'CMD ["python", {json.dumps(entry_point, ensure_ascii=False)}]' + ) + return "\n".join( [ f"FROM {_AGENTKIT_BASE_IMAGES[cloud_provider]}", @@ -2183,7 +2194,7 @@ def render_default_agentkit_dockerfile(cloud_provider: str) -> str: "WORKDIR /app", "COPY . .", "", - 'CMD ["python", "-m", "app"]', + startup_command, "", ] )