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
10 changes: 8 additions & 2 deletions frontend/server/intelligent_development.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def main():
fail("Delivery request is invalid")
request_path = Path(sys.argv[1])
request = json.loads(read_regular(request_path).decode("utf-8"))
if set(request) != {"projectRoot", "report", "secretPath", "agentName", "entryPoint", "manifestSha256"}:
if set(request) != {"projectRoot", "report", "secretPath", "agentName", "entryPoint", "fallbackEntryPoint", "manifestSha256"}:
fail("Delivery request fields are invalid")
project = project_path(request["projectRoot"])
secret_path = Path(request["secretPath"])
Expand Down Expand Up @@ -258,9 +258,15 @@ def main():
fail("Delivery agentkit.yaml changed during packaging")
agent_name = request["agentName"]
entry_point = request["entryPoint"]
fallback_entry_point = request["fallbackEntryPoint"]
if not isinstance(agent_name, str) or not agent_name.strip():
fail("Delivery agent name is invalid")
if not isinstance(entry_point, str) or entry_point not in {name for name, _ in files}:
if not isinstance(entry_point, str) or not isinstance(fallback_entry_point, str):
fail("Delivery entry point is invalid")
source_names = {name for name, _ in files}
if entry_point not in source_names and fallback_entry_point in source_names:
entry_point = fallback_entry_point
if entry_point not in source_names:
fail("Delivery entry point is invalid")
report["agentName"] = agent_name.strip()
report["entryPoint"] = entry_point
Expand Down
11 changes: 10 additions & 1 deletion frontend/server/intelligent_development_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,7 @@ async def _interrupt(session_id: str, request: Request) -> dict[str, bool]:
async def _message(session_id: str, request: Request) -> StreamingResponse:
owner = owner_resolver(request)
project_context = ""
trusted_manifest_metadata: tuple[str, str] | None = None
try:
data = await _request_object(request, 128 * 1024)
if set(data) != {"message"}:
Expand All @@ -1280,6 +1281,7 @@ async def _message(session_id: str, request: Request) -> StreamingResponse:
)
base = await project_service.base_metadata(owner, session_id)
if base is not None:
trusted_manifest_metadata = (base.agent_name, base.entry_point)
project_context = json.dumps(
{
"intentSummary": base.intent_summary,
Expand Down Expand Up @@ -1468,6 +1470,7 @@ async def cleanup_task_files() -> None:
completion=completion,
exact_secrets=lease.exact_secrets,
acceptance_criteria=decision.acceptance_criteria,
trusted_manifest_metadata=trusted_manifest_metadata,
)
stored_version = None
persistence_error: dict[str, object] | None = None
Expand Down Expand Up @@ -1596,7 +1599,13 @@ async def cleanup_task_files() -> None:
payload = _stream_error_payload(failure)
yield f"event: error\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
yield 'event: done\ndata: {"reason":"failed"}\n\n'
except Exception: # noqa: BLE001
except Exception as error: # noqa: BLE001
logger.error(
"Unexpected intelligent development turn failure stage=%s error_type=%s session_id=%s",
failure_stage,
type(error).__name__,
session_id,
)
try:
await cleanup_task_files()
except SandboxError as cleanup_error:
Expand Down
108 changes: 93 additions & 15 deletions frontend/server/intelligent_development_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,18 +726,9 @@ async def read_completion_contract(
return parse_completion_contract(content)


def _delivery_manifest_metadata(content: bytes) -> tuple[str, str]:
if len(content) > _MAX_MANIFEST_BYTES:
raise ValueError("Delivery agentkit.yaml is too large")
try:
manifest = yaml.safe_load(content)
except (UnicodeDecodeError, yaml.YAMLError) as error:
raise ValueError("Delivery agentkit.yaml is invalid") from error
common = manifest.get("common") if isinstance(manifest, dict) else None
if not isinstance(common, dict):
raise ValueError("Delivery agentkit.yaml common is invalid")
agent_name = common.get("agent_name") or common.get("name")
entry_point = common.get("entry_point")
def _validate_delivery_metadata(
agent_name: object, entry_point: object
) -> tuple[str, str]:
if (
not isinstance(agent_name, str)
or not agent_name.strip()
Expand All @@ -759,6 +750,48 @@ def _delivery_manifest_metadata(content: bytes) -> tuple[str, str]:
return agent_name.strip(), entry_point


def _delivery_manifest_metadata(
content: bytes,
*,
trusted_fallback: tuple[str, str] | None = None,
) -> tuple[str, str]:
if len(content) > _MAX_MANIFEST_BYTES:
raise ValueError("Delivery agentkit.yaml is too large")
if not content:
if trusted_fallback is None:
raise ValueError("Delivery agentkit.yaml is missing")
return _validate_delivery_metadata(*trusted_fallback)
try:
manifest = yaml.safe_load(content)
except (UnicodeDecodeError, yaml.YAMLError) as error:
raise ValueError("Delivery agentkit.yaml is invalid") from error
if not isinstance(manifest, dict):
raise ValueError("Delivery agentkit.yaml is invalid")
common = manifest.get("common")
if common is None and trusted_fallback is not None:
return _validate_delivery_metadata(*trusted_fallback)
if not isinstance(common, dict):
raise ValueError("Delivery agentkit.yaml common is invalid")
if common.get("agent_name"):
agent_name = common["agent_name"]
elif "name" in common:
agent_name = common["name"]
elif "agent_name" in common:
agent_name = common["agent_name"]
elif trusted_fallback is not None:
agent_name = trusted_fallback[0]
else:
agent_name = None
entry_point = (
common["entry_point"]
if "entry_point" in common
else trusted_fallback[1]
if trusted_fallback is not None
else None
)
return _validate_delivery_metadata(agent_name, entry_point)


class DeliveryPublisher:
"""Package an immutable source snapshot without re-running validation."""

Expand All @@ -774,7 +807,13 @@ async def publish(
completion: CompletionContract | None,
exact_secrets: tuple[str, ...],
acceptance_criteria: tuple[str, ...] = (),
trusted_manifest_metadata: tuple[str, str] | None = None,
) -> DeliveryReference:
trusted_metadata = (
_validate_delivery_metadata(*trusted_manifest_metadata)
if trusted_manifest_metadata is not None
else None
)
token = uuid4().hex
worker_path = f"{task_root}/delivery-{token}.py"
request_path = f"{task_root}/delivery-{token}.json"
Expand Down Expand Up @@ -813,16 +852,21 @@ async def publish(
),
"steps": steps,
}
manifest_bytes = await self._transport.download(
f"{project_root}/agentkit.yaml", max_bytes=_MAX_MANIFEST_BYTES
manifest_bytes = await self._manifest_bytes(
project_root,
allow_missing=trusted_metadata is not None,
)
agent_name, entry_point = _delivery_manifest_metadata(
manifest_bytes,
trusted_fallback=trusted_metadata,
)
agent_name, entry_point = _delivery_manifest_metadata(manifest_bytes)
request = {
"projectRoot": project_root,
"report": report,
"secretPath": secret_path,
"agentName": agent_name,
"entryPoint": entry_point,
"fallbackEntryPoint": trusted_metadata[1] if trusted_metadata else "",
"manifestSha256": hashlib.sha256(manifest_bytes).hexdigest(),
}
await self._transport.upload(
Expand Down Expand Up @@ -857,6 +901,40 @@ async def publish(
finally:
await self._unlink_many(secret_path, request_path, worker_path)

async def _manifest_bytes(self, project_root: str, *, allow_missing: bool) -> bytes:
manifest_path = f"{project_root}/agentkit.yaml"
if not allow_missing:
return await self._transport.download(
manifest_path, max_bytes=_MAX_MANIFEST_BYTES
)
source = (
"import json,os,stat\n"
f"path={manifest_path!r}\n"
"try: metadata=os.lstat(path)\n"
"except FileNotFoundError: value={'state':'missing'}\n"
"else:\n"
" value={'state':'regular','size':metadata.st_size} if stat.S_ISREG(metadata.st_mode) else {'state':'unsafe'}\n"
"print(json.dumps(value,separators=(',',':')))\n"
)
status = await self._transport.exec_json(
f"python3 -c {shlex.quote(source)}", timeout=12
)
if status == {"state": "missing"}:
return b""
size = status.get("size")
if (
set(status) != {"state", "size"}
or status.get("state") != "regular"
or isinstance(size, bool)
or not isinstance(size, int)
or size < 0
or size > _MAX_MANIFEST_BYTES
):
raise ValueError("Delivery agentkit.yaml is unsafe")
return await self._transport.download(
manifest_path, max_bytes=_MAX_MANIFEST_BYTES
)

async def _unlink_many(self, *paths: str) -> None:
source = (
"import os\n"
Expand Down
16 changes: 16 additions & 0 deletions frontend/service/studio_scheduler/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
from pathlib import Path
from typing import Any

from frontend.service.studio_release_server.offline_runtime import (
STUDIO_RUNTIME_LOCK,
STUDIO_RUNTIME_WHEELHOUSE,
)

from .diagnostics import sanitize_diagnostic

_SCAN_TIMER_NAME = "veadk-studio-cronjobs-minute"
Expand Down Expand Up @@ -256,6 +261,17 @@ def _stage_package(package_root: Path, destination: Path) -> None:
if not requirements.is_file():
raise ValueError("Studio scheduler package is missing requirements.txt")
shutil.copy2(requirements, destination / requirements.name)

runtime_lock = package_root / STUDIO_RUNTIME_LOCK
if runtime_lock.is_file():
shutil.copy2(runtime_lock, destination / runtime_lock.name)

wheelhouse = package_root / STUDIO_RUNTIME_WHEELHOUSE
if wheelhouse.is_dir():
shutil.copytree(wheelhouse, destination / wheelhouse.name)

# Preserve compatibility with packages produced before the offline
# wheelhouse layout was introduced.
for wheel in package_root.glob("*.whl"):
shutil.copy2(wheel, destination / wheel.name)
run_script = destination / "run.sh"
Expand Down
46 changes: 43 additions & 3 deletions tests/frontend/server/test_intelligent_development_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1610,9 +1610,8 @@ def test_restored_project_context_selects_incremental_builder_mode(
routes, "read_completion_contract", AsyncMock(return_value=_partial())
)
monkeypatch.setattr(routes, "remove_completion_file", AsyncMock())
monkeypatch.setattr(
routes, "DeliveryPublisher", lambda _transport: _publisher_mock()
)
publisher = _publisher_mock()
monkeypatch.setattr(routes, "DeliveryPublisher", lambda _transport: publisher)

with TestClient(_app(gateway, project_service=project_service)) as client:
_connect(client)
Expand All @@ -1630,6 +1629,10 @@ def test_restored_project_context_selects_incremental_builder_mode(
assert '"agentName":"weather_agent"' in builder
assert "Do not run `ak init`" in builder
assert "use `ak init --template agent_server` by default" not in builder
assert publisher.publish.await_args.kwargs["trusted_manifest_metadata"] == (
"weather_agent",
"app.py",
)


def test_builder_uses_preinstalled_skill_without_discovery_or_injection(
Expand Down Expand Up @@ -2387,6 +2390,43 @@ def test_delivery_persistence_failures_keep_distinct_sse_semantics(
assert "event: development.succeeded" not in response.text


def test_unexpected_snapshot_failure_logs_stage_and_type_without_error_detail(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
gateway = _FakeGateway()
gateway.sessions["dev-session"] = _cloud()
gateway.codex.turns = [
[CodexAppServerEvent(kind="text", text="源码已生成")],
]
lease = _Lease(_Remote(gateway.sessions["dev-session"].endpoint))
monkeypatch.setattr(
routes, "create_credential_lease", AsyncMock(return_value=lease)
)
monkeypatch.setattr(routes, "invalidate_current_delivery", AsyncMock())
monkeypatch.setattr(
routes, "read_completion_contract", AsyncMock(return_value=_partial())
)
monkeypatch.setattr(routes, "remove_completion_file", AsyncMock())
publisher = _publisher_mock()
publisher.publish.side_effect = RuntimeError("private upstream detail")
monkeypatch.setattr(routes, "DeliveryPublisher", lambda _transport: publisher)

with caplog.at_level("ERROR", logger=routes.__name__):
with TestClient(_app(gateway)) as client:
_connect(client)
response = client.post(
"/web/intelligent-development/sessions/dev-session/messages",
headers={"X-Test-User": "alice"},
json={"message": "做一个天气 Agent"},
)

assert '"code": "INTELLIGENT_DEVELOPMENT_FAILED"' in response.text
assert "stage=delivery_publish" in caplog.text
assert "error_type=RuntimeError" in caplog.text
assert "private upstream detail" not in caplog.text


def test_builder_response_cannot_replace_a_missing_completion_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
Loading
Loading