Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions frontend/server/intelligent_development.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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"):
Expand Down
15 changes: 3 additions & 12 deletions frontend/server/migration/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion tests/frontend/server/test_intelligent_development_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"{}",
},
)
Expand All @@ -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",
Expand All @@ -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"),
)
Expand All @@ -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(
Expand Down
68 changes: 63 additions & 5 deletions tests/frontend/test_migration_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3433,14 +3433,15 @@ def test_service_recovers_terminal_state_and_verified_artifact_from_session() ->


@pytest.mark.parametrize(
("delivery_state", "verification"),
("delivery_state", "verification", "deploy_ready"),
[
(
"succeeded",
{
"status": "passed",
"checks": [{"name": "import", "status": "passed"}],
},
True,
),
(
"partial",
Expand All @@ -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)
Expand Down Expand Up @@ -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",
}
Expand All @@ -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(
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down
Loading