agentenv-framework 0.9.1254__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""Snapshot a deployed env's live service state into a new EnvironmentUniverseArtifact."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import ClassVar, Optional
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from agentenv_protocol import FilePart
|
|
20
|
+
|
|
21
|
+
from agent_env.env.gateway.constants import EXT_TRAJECTORY_URI
|
|
22
|
+
from agent_env.store import get_config
|
|
23
|
+
from agent_env.store.object_store import S3ObjectStore
|
|
24
|
+
from agent_env.store.routing import in_local_run
|
|
25
|
+
from agent_env.task_step.context import TaskStepContext
|
|
26
|
+
from agent_env.entity_refs import EntityRef, RefRole
|
|
27
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
DEFAULT_EXPORT_TIMEOUT_SECONDS = 600
|
|
32
|
+
DEFAULT_EXPORT_CONCURRENCY = 3
|
|
33
|
+
_CONNECT_TIMEOUT_SECONDS = 30
|
|
34
|
+
|
|
35
|
+
_TRAJECTORY_CAPTURE_BUDGET_SECONDS = 300
|
|
36
|
+
|
|
37
|
+
# Must match the S3 credentials extension URI the server-side credentials mixin advertises.
|
|
38
|
+
_S3_CREDENTIALS_EXTENSION_URI = "urn:agentenv:add-s3-credentials/v1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _push_s3_credentials(base_url: str, card: dict, timeout_seconds: float) -> None:
|
|
42
|
+
"""Best-effort: push the object store's shared AWS creds + bucket to a service
|
|
43
|
+
advertising add_s3_credentials, if the store shares any. Never raises (get_data falls back).
|
|
44
|
+
An @local run's snapshots land in the local stores, so it hands a service no credentials to
|
|
45
|
+
upload one to S3 with; the service returns it instead."""
|
|
46
|
+
from agentenv_protocol import client as protocol_v1
|
|
47
|
+
|
|
48
|
+
if protocol_v1.find_extension(card, _S3_CREDENTIALS_EXTENSION_URI) is None or in_local_run():
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
store = get_config().get_object_store()
|
|
52
|
+
if not isinstance(store, S3ObjectStore):
|
|
53
|
+
return
|
|
54
|
+
env = await asyncio.to_thread(store.shared_credentials_env)
|
|
55
|
+
if "AWS_ACCESS_KEY_ID" not in env:
|
|
56
|
+
return
|
|
57
|
+
params = {
|
|
58
|
+
"aws_access_key_id": env["AWS_ACCESS_KEY_ID"],
|
|
59
|
+
"aws_secret_access_key": env["AWS_SECRET_ACCESS_KEY"],
|
|
60
|
+
"aws_session_token": env.get("AWS_SESSION_TOKEN"),
|
|
61
|
+
"region_name": env.get("AWS_DEFAULT_REGION"),
|
|
62
|
+
"bucket": store.bucket,
|
|
63
|
+
}
|
|
64
|
+
await protocol_v1.invoke_extension(
|
|
65
|
+
base_url,
|
|
66
|
+
card,
|
|
67
|
+
_S3_CREDENTIALS_EXTENSION_URI,
|
|
68
|
+
params,
|
|
69
|
+
timeout=int(timeout_seconds),
|
|
70
|
+
)
|
|
71
|
+
logger.info("snapshot_env: pushed S3 creds to %s", base_url)
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"snapshot_env: failed to push S3 creds to %s (get_data will fall back)",
|
|
75
|
+
base_url,
|
|
76
|
+
exc_info=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class EnvSnapshotResult:
|
|
82
|
+
environment_universe_artifact_id: str
|
|
83
|
+
environment_universe_artifact_version: int
|
|
84
|
+
environments_snapshotted: list[str]
|
|
85
|
+
total: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SnapshotEnvTaskStep(TaskStep):
|
|
89
|
+
"""Snapshot a deployed env's current service state into a new
|
|
90
|
+
``EnvironmentUniverseArtifact``.
|
|
91
|
+
|
|
92
|
+
Picks the env from ``context.deployed_envs`` by ``env_instance_id``, else
|
|
93
|
+
``env_id``, else the context's single env; ``env_step_id`` narrows that to
|
|
94
|
+
one ``deploy_env`` step's deployment when an env is deployed more than once.
|
|
95
|
+
``gateway_url``, ``snapshot_id`` and ``original_universe_artifact_id`` are
|
|
96
|
+
optional overrides; universe metadata carries over from that id, else the
|
|
97
|
+
loaded universe. The generated snapshot id is stable across
|
|
98
|
+
retries of a run, so retries bump versions instead of minting
|
|
99
|
+
duplicate universes.
|
|
100
|
+
|
|
101
|
+
Output: ``context.metadata["env_snapshotted_universes"][self.id]`` =
|
|
102
|
+
``{"id", "version"}``. Fails the step if any service fails to export.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
type: ClassVar[str] = "snapshot_env"
|
|
106
|
+
entity_refs = (
|
|
107
|
+
EntityRef.env("env_id"),
|
|
108
|
+
EntityRef.artifact("snapshot_id", role=RefRole.OUTPUT, artifact_type="environment_universe"),
|
|
109
|
+
EntityRef.artifact("original_universe_artifact_id", artifact_type="environment_universe"),
|
|
110
|
+
)
|
|
111
|
+
DEFAULT_EXPORT_TIMEOUT_SECONDS: ClassVar[int] = DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
id: str,
|
|
116
|
+
version: Optional[int],
|
|
117
|
+
env_id: Optional[str] = None,
|
|
118
|
+
env_instance_id: Optional[str] = None,
|
|
119
|
+
env_step_id: Optional[str] = None,
|
|
120
|
+
gateway_url: Optional[str] = None,
|
|
121
|
+
snapshot_id: Optional[str] = None,
|
|
122
|
+
original_universe_artifact_id: Optional[str] = None,
|
|
123
|
+
export_timeout_seconds: int = DEFAULT_EXPORT_TIMEOUT_SECONDS,
|
|
124
|
+
include_env_trajectory: bool = False,
|
|
125
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
126
|
+
fail_task_on_error: bool = True,
|
|
127
|
+
):
|
|
128
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
129
|
+
self.env_id = env_id
|
|
130
|
+
self.env_instance_id = env_instance_id
|
|
131
|
+
self.env_step_id = env_step_id
|
|
132
|
+
self.gateway_url = gateway_url
|
|
133
|
+
self.snapshot_id = snapshot_id
|
|
134
|
+
self.original_universe_artifact_id = original_universe_artifact_id
|
|
135
|
+
self.export_timeout_seconds = export_timeout_seconds
|
|
136
|
+
self.include_env_trajectory = include_env_trajectory
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict:
|
|
139
|
+
base = super().to_dict()
|
|
140
|
+
base["env_id"] = self.env_id
|
|
141
|
+
base["env_instance_id"] = self.env_instance_id
|
|
142
|
+
base["env_step_id"] = self.env_step_id
|
|
143
|
+
base["gateway_url"] = self.gateway_url
|
|
144
|
+
base["snapshot_id"] = self.snapshot_id
|
|
145
|
+
base["original_universe_artifact_id"] = self.original_universe_artifact_id
|
|
146
|
+
base["export_timeout_seconds"] = self.export_timeout_seconds
|
|
147
|
+
base["include_env_trajectory"] = self.include_env_trajectory
|
|
148
|
+
return base
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_dict(cls, data: dict) -> SnapshotEnvTaskStep:
|
|
152
|
+
return cls(
|
|
153
|
+
**cls._base_from_dict(data),
|
|
154
|
+
env_id=data.get("env_id"),
|
|
155
|
+
env_instance_id=data.get("env_instance_id"),
|
|
156
|
+
env_step_id=data.get("env_step_id"),
|
|
157
|
+
gateway_url=data.get("gateway_url"),
|
|
158
|
+
snapshot_id=data.get("snapshot_id"),
|
|
159
|
+
original_universe_artifact_id=data.get("original_universe_artifact_id"),
|
|
160
|
+
export_timeout_seconds=data.get(
|
|
161
|
+
"export_timeout_seconds", cls.DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
162
|
+
),
|
|
163
|
+
include_env_trajectory=data.get("include_env_trajectory", False),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
167
|
+
from agent_env.env.env import Env, gateway_url_of
|
|
168
|
+
|
|
169
|
+
deployed = self._resolve_deployed_env(context)
|
|
170
|
+
env_id = self.env_id or (deployed.env_id if deployed else None)
|
|
171
|
+
if not env_id:
|
|
172
|
+
raise RuntimeError("snapshot_env could not resolve an env_id")
|
|
173
|
+
gateway_url = self.gateway_url or gateway_url_of(deployed)
|
|
174
|
+
if not gateway_url:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"Deployed env '{env_id}' has no gateway_url and no override was provided"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if self.include_env_trajectory:
|
|
180
|
+
entry: dict = {"captured_at_utc": datetime.now(timezone.utc).isoformat(), "capture_step_id": self.id}
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.wait_for(self._capture_env_trajectory(entry, context, env_id, deployed), timeout=_TRAJECTORY_CAPTURE_BUDGET_SECONDS)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
detail = f"{type(e).__name__}: {str(e)[:200]}"
|
|
185
|
+
logger.warning(f"snapshot_env: env trajectory capture aborted (continuing): {detail}")
|
|
186
|
+
entry["error"] = detail
|
|
187
|
+
context.metadata.setdefault("env_trajectory", {})[env_id] = entry
|
|
188
|
+
|
|
189
|
+
# Pin the env version the sandbox actually runs, so service enumeration
|
|
190
|
+
# can't drift if the env was re-registered since this sandbox deployed.
|
|
191
|
+
env_version = deployed.env_version if deployed else None
|
|
192
|
+
env = await asyncio.to_thread(Env.get, env_id, env_version)
|
|
193
|
+
|
|
194
|
+
snapshot_id = self._derive_snapshot_id(context, env_id)
|
|
195
|
+
result = await self.snapshot_env_state(
|
|
196
|
+
env=env,
|
|
197
|
+
gateway_url=gateway_url,
|
|
198
|
+
snapshot_id=snapshot_id,
|
|
199
|
+
deployed=deployed,
|
|
200
|
+
original_universe_artifact_id=self.original_universe_artifact_id,
|
|
201
|
+
export_timeout_seconds=self.export_timeout_seconds,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
logger.info(
|
|
205
|
+
f"snapshot_env: {env_id} -> {result.environment_universe_artifact_id} "
|
|
206
|
+
f"v{result.environment_universe_artifact_version} "
|
|
207
|
+
f"({len(result.environments_snapshotted)}/{result.total} services)"
|
|
208
|
+
)
|
|
209
|
+
# Identifiers only — the context is heartbeated into task_instances.
|
|
210
|
+
context.metadata.setdefault("env_snapshotted_universes", {})[self.id] = {
|
|
211
|
+
"id": result.environment_universe_artifact_id,
|
|
212
|
+
"version": result.environment_universe_artifact_version,
|
|
213
|
+
}
|
|
214
|
+
return context
|
|
215
|
+
|
|
216
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _deploy_step_of(deployed_env) -> Optional[str]:
|
|
220
|
+
return (deployed_env.metadata or {}).get("deploy_step_id")
|
|
221
|
+
|
|
222
|
+
def _select_by_step_id(self, candidates: list) -> list:
|
|
223
|
+
"""The candidates ``env_step_id`` names; all of them when it isn't set."""
|
|
224
|
+
if self.env_step_id is None:
|
|
225
|
+
return candidates
|
|
226
|
+
# A filter, not a hint: an unmatched id is an error, never a substitute.
|
|
227
|
+
# Empty candidates are left to the callers' own not-deployed handling.
|
|
228
|
+
named = [d for d in candidates if self._deploy_step_of(d) == self.env_step_id]
|
|
229
|
+
if candidates and not named:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"snapshot_env '{self.id}': env_step_id '{self.env_step_id}' names no deployment of "
|
|
232
|
+
f"{self._env_label} (deployed by: {[self._deploy_step_of(d) for d in candidates]})"
|
|
233
|
+
)
|
|
234
|
+
return named
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def _env_label(self) -> str:
|
|
238
|
+
return f"env '{self.env_id}'" if self.env_id else "any deployed env"
|
|
239
|
+
|
|
240
|
+
def _resolve_deployed_env(self, context: TaskStepContext):
|
|
241
|
+
"""The DeployedEnv this step targets, or None in pure-override mode."""
|
|
242
|
+
if self.env_instance_id:
|
|
243
|
+
deployed = next(
|
|
244
|
+
(d for d in context.deployed_envs if d.instance_id == self.env_instance_id),
|
|
245
|
+
None,
|
|
246
|
+
)
|
|
247
|
+
if deployed is None:
|
|
248
|
+
raise RuntimeError(
|
|
249
|
+
f"No deployed env with instance_id '{self.env_instance_id}' in context "
|
|
250
|
+
f"(have: {[d.instance_id for d in context.deployed_envs]})"
|
|
251
|
+
)
|
|
252
|
+
return deployed
|
|
253
|
+
if self.env_id:
|
|
254
|
+
matches = self._select_by_step_id(
|
|
255
|
+
[d for d in context.deployed_envs if d.env_id == self.env_id]
|
|
256
|
+
)
|
|
257
|
+
if len(matches) > 1:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"snapshot_env '{self.id}': {len(matches)} deployments of env '{self.env_id}' "
|
|
260
|
+
f"(deployed by: {[self._deploy_step_of(d) for d in matches]}). Set env_step_id "
|
|
261
|
+
"to the deploy_env step this snapshot should target."
|
|
262
|
+
)
|
|
263
|
+
deployed = matches[0] if matches else None
|
|
264
|
+
if deployed is None and self.gateway_url:
|
|
265
|
+
return None # pure-override mode: env_id + gateway_url suffice
|
|
266
|
+
if deployed is None:
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"No deployed env with env_id '{self.env_id}' in context "
|
|
269
|
+
f"(have: {[d.env_id for d in context.deployed_envs]}) and no "
|
|
270
|
+
"gateway_url override was provided"
|
|
271
|
+
)
|
|
272
|
+
return deployed
|
|
273
|
+
# `env_step_id` alone also names one of k, so it answers this ambiguity.
|
|
274
|
+
candidates = self._select_by_step_id(context.deployed_envs)
|
|
275
|
+
if len(candidates) == 1:
|
|
276
|
+
return candidates[0]
|
|
277
|
+
raise RuntimeError(
|
|
278
|
+
"snapshot_env needs env_id, env_instance_id or env_step_id when the context has "
|
|
279
|
+
f"{len(context.deployed_envs)} deployed envs "
|
|
280
|
+
f"(have: {[d.env_id for d in context.deployed_envs]})"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None:
|
|
284
|
+
"""Stream the trajectory to a temp file, upload it verbatim (no envelope), and fill ``entry``."""
|
|
285
|
+
method, url = _trajectory_route(deployed, self.gateway_url)
|
|
286
|
+
store = get_config().get_object_store()
|
|
287
|
+
event_count = 0
|
|
288
|
+
timeout = httpx.Timeout(_TRAJECTORY_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS)
|
|
289
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
|
|
290
|
+
try:
|
|
291
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
292
|
+
async with client.stream(method, url) as response:
|
|
293
|
+
response.raise_for_status()
|
|
294
|
+
async for chunk in response.aiter_bytes():
|
|
295
|
+
tmp.write(chunk)
|
|
296
|
+
event_count += chunk.count(b"\n")
|
|
297
|
+
tmp.close()
|
|
298
|
+
instance_key = context.instance_id or f"adhoc-{uuid.uuid4().hex[:12]}"
|
|
299
|
+
key = (
|
|
300
|
+
f"{get_config().get_artifact_key_prefix()}env_trajectory/"
|
|
301
|
+
f"instance_id={instance_key}/{env_id}-{uuid.uuid4().hex[:8]}.jsonl"
|
|
302
|
+
)
|
|
303
|
+
object_url = await asyncio.to_thread(store.put_file, key, tmp.name, "application/jsonl")
|
|
304
|
+
finally:
|
|
305
|
+
tmp.close()
|
|
306
|
+
os.unlink(tmp.name)
|
|
307
|
+
entry["object_url"] = object_url
|
|
308
|
+
entry["event_count"] = event_count
|
|
309
|
+
|
|
310
|
+
def _derive_snapshot_id(self, context: TaskStepContext, env_id: str) -> str:
|
|
311
|
+
if self.snapshot_id:
|
|
312
|
+
return self.snapshot_id
|
|
313
|
+
instance_id = context.instance_id or context.metadata.get("instance_id")
|
|
314
|
+
if instance_id:
|
|
315
|
+
# Stable per run instance: activity retries re-put the same ids.
|
|
316
|
+
return f"snapshot-{env_id}-{instance_id.rsplit('-', 1)[-1][:16]}"
|
|
317
|
+
generated = f"snapshot-{env_id}-{uuid.uuid4().hex[:8]}"
|
|
318
|
+
logger.warning(
|
|
319
|
+
f"snapshot_env: no instance_id in context; using random snapshot_id "
|
|
320
|
+
f"{generated} (retries will not be idempotent)"
|
|
321
|
+
)
|
|
322
|
+
return generated
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _resolve_loaded_universe_ref(deployed) -> Optional[tuple[str, Optional[int]]]:
|
|
326
|
+
"""(id, version) of the universe this instance recorded loading, or None (best-effort)."""
|
|
327
|
+
instance_id = getattr(deployed, "instance_id", None) if deployed else None
|
|
328
|
+
if not instance_id:
|
|
329
|
+
return None
|
|
330
|
+
try:
|
|
331
|
+
from agent_env.env.store import get_env_instance_store
|
|
332
|
+
|
|
333
|
+
su = get_env_instance_store().get_environment_universe(instance_id)
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.warning("snapshot_env: could not read loaded universe for %s", instance_id, exc_info=True)
|
|
336
|
+
return None
|
|
337
|
+
return (su["id"], su.get("version")) if su and su.get("id") else None
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _enumerate_environments(env) -> list[str]:
|
|
341
|
+
"""environment names for a multi env or a single MCP server env."""
|
|
342
|
+
if getattr(env, "mcp_server_envs", None):
|
|
343
|
+
return [e.environment_name for e in env.mcp_server_envs]
|
|
344
|
+
if getattr(env, "environment_name", None):
|
|
345
|
+
return [env.environment_name]
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"Env '{env.id}' v{env.version} has no MCP services to snapshot "
|
|
348
|
+
"(expected mcp_server_envs or environment_name)"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
async def _export_environment_to_file(
|
|
353
|
+
gateway_url: str, environment_name: str, tmp_path: str, timeout_seconds: float, deployed=None
|
|
354
|
+
) -> str:
|
|
355
|
+
"""Export one service; return the suffix written (".json"/".zip") to
|
|
356
|
+
``tmp_path``, or the object url a FilePart names when the service uploaded the
|
|
357
|
+
bundle itself (nothing written to ``tmp_path`` then — export_one registers it).
|
|
358
|
+
|
|
359
|
+
v1 ``get_data``: ``DataPart`` → json; ``FilePart`` → object url (no
|
|
360
|
+
download) / base64 ``bytes`` / streamed relative-or-http ``uri`` → ".zip";
|
|
361
|
+
else legacy ``GET /export-state`` streamed to disk with a first-byte JSON
|
|
362
|
+
guard. Errors are caught by the caller (fails just this service)."""
|
|
363
|
+
from agent_env.env import legacy_protocol
|
|
364
|
+
from agentenv_protocol import client as protocol_v1
|
|
365
|
+
|
|
366
|
+
base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=True)
|
|
367
|
+
if base_url is not None:
|
|
368
|
+
# Push S3 creds (no-op unless the service advertises the extension).
|
|
369
|
+
card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name)
|
|
370
|
+
await _push_s3_credentials(card_base, card or {}, timeout_seconds)
|
|
371
|
+
resp = await protocol_v1.get_data(base_url, timeout=int(timeout_seconds))
|
|
372
|
+
part = resp.parts[0] if resp.parts else None
|
|
373
|
+
if isinstance(part, FilePart):
|
|
374
|
+
raw = getattr(part.file, "bytes", None)
|
|
375
|
+
if raw is not None:
|
|
376
|
+
with open(tmp_path, "wb") as f:
|
|
377
|
+
f.write(base64.b64decode(raw))
|
|
378
|
+
else:
|
|
379
|
+
uri = getattr(part.file, "uri", None)
|
|
380
|
+
if not uri:
|
|
381
|
+
# ValueError, not TypeError, so export_one catches it and
|
|
382
|
+
# fails only this service — not the whole snapshot.
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"FilePart for '{environment_name}' has neither bytes nor uri"
|
|
385
|
+
)
|
|
386
|
+
# Any url but http(s) is an object the service uploaded itself — hand
|
|
387
|
+
# it back as-is; export_one registers it directly (no download). Its
|
|
388
|
+
# last segment carries the shape (e.g. gdrive.zip).
|
|
389
|
+
scheme = urlparse(uri).scheme
|
|
390
|
+
if scheme and scheme not in ("http", "https"):
|
|
391
|
+
return uri
|
|
392
|
+
# A relative uri (e.g. "export-snapshot") means "stream it from
|
|
393
|
+
# my service endpoint" — resolve against the service base_url so
|
|
394
|
+
# multi-GB bundles never ride inline in the JSON-RPC response.
|
|
395
|
+
if not scheme:
|
|
396
|
+
uri = f"{base_url.rstrip('/')}/{uri.lstrip('/')}"
|
|
397
|
+
async with httpx.AsyncClient() as client:
|
|
398
|
+
async with client.stream(
|
|
399
|
+
"GET",
|
|
400
|
+
uri,
|
|
401
|
+
timeout=httpx.Timeout(
|
|
402
|
+
timeout_seconds, connect=_CONNECT_TIMEOUT_SECONDS
|
|
403
|
+
),
|
|
404
|
+
) as resp_file:
|
|
405
|
+
resp_file.raise_for_status()
|
|
406
|
+
with open(tmp_path, "wb") as f:
|
|
407
|
+
async for chunk in resp_file.aiter_bytes():
|
|
408
|
+
f.write(chunk)
|
|
409
|
+
return os.path.splitext(getattr(part.file, "name", "") or "")[1] or ".zip"
|
|
410
|
+
state = part.data if part is not None else {}
|
|
411
|
+
with open(tmp_path, "w") as f:
|
|
412
|
+
json.dump(state, f, default=str)
|
|
413
|
+
return ".json"
|
|
414
|
+
|
|
415
|
+
legacy_url = f"{legacy_protocol.environment_base_url(gateway_url, environment_name, mcp=True)}/export-state"
|
|
416
|
+
async with httpx.AsyncClient() as client:
|
|
417
|
+
async with client.stream(
|
|
418
|
+
"GET",
|
|
419
|
+
legacy_url,
|
|
420
|
+
timeout=httpx.Timeout(timeout_seconds, connect=_CONNECT_TIMEOUT_SECONDS),
|
|
421
|
+
) as resp:
|
|
422
|
+
resp.raise_for_status()
|
|
423
|
+
seen_first_bytes = False
|
|
424
|
+
with open(tmp_path, "wb") as f:
|
|
425
|
+
async for chunk in resp.aiter_bytes():
|
|
426
|
+
if not seen_first_bytes and chunk:
|
|
427
|
+
lead = chunk.lstrip()[:1]
|
|
428
|
+
if lead not in (b"{", b"["):
|
|
429
|
+
raise ValueError(
|
|
430
|
+
f"export-state returned non-JSON body (starts with {lead!r})"
|
|
431
|
+
)
|
|
432
|
+
seen_first_bytes = True
|
|
433
|
+
f.write(chunk)
|
|
434
|
+
return ".json"
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
async def snapshot_env_state(
|
|
438
|
+
cls,
|
|
439
|
+
*,
|
|
440
|
+
env,
|
|
441
|
+
gateway_url: str,
|
|
442
|
+
snapshot_id: str,
|
|
443
|
+
deployed=None,
|
|
444
|
+
original_universe_artifact_id: Optional[str] = None,
|
|
445
|
+
export_timeout_seconds: int = DEFAULT_EXPORT_TIMEOUT_SECONDS,
|
|
446
|
+
concurrency: int = DEFAULT_EXPORT_CONCURRENCY,
|
|
447
|
+
) -> EnvSnapshotResult:
|
|
448
|
+
"""Export all services via the gateway into a new EnvironmentUniverseArtifact
|
|
449
|
+
``snapshot_id``. All-or-nothing; the raised error carries public
|
|
450
|
+
per-service summaries (verbose details go to the log).
|
|
451
|
+
|
|
452
|
+
A classmethod so callers that have no step instance can reuse it."""
|
|
453
|
+
from agent_env.artifact import FileArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact
|
|
454
|
+
from agent_env.env.env import gateway_url_of
|
|
455
|
+
|
|
456
|
+
services = cls._enumerate_environments(env)
|
|
457
|
+
gateway = gateway_url.rstrip("/")
|
|
458
|
+
# `gateway` identifies which deployment was exported: with k deployments of
|
|
459
|
+
# one env_id, env= and snapshot_id= are identical across branches.
|
|
460
|
+
logger.info(
|
|
461
|
+
f"snapshot_env: env={env.id} v{env.version} snapshot_id={snapshot_id} "
|
|
462
|
+
f"gateway={gateway} services={services}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Carry the source universe's metadata into the snapshot: explicit
|
|
466
|
+
# original id (fail-fast), else the loaded universe (best-effort).
|
|
467
|
+
snapshot_metadata = None
|
|
468
|
+
source_id = original_universe_artifact_id
|
|
469
|
+
explicit = source_id is not None
|
|
470
|
+
source_version: Optional[int] = None
|
|
471
|
+
if not explicit:
|
|
472
|
+
source_id, source_version = cls._resolve_loaded_universe_ref(deployed) or (None, None)
|
|
473
|
+
if source_id:
|
|
474
|
+
try:
|
|
475
|
+
source = await asyncio.to_thread(
|
|
476
|
+
EnvironmentUniverseArtifact.get, source_id, version=source_version
|
|
477
|
+
)
|
|
478
|
+
snapshot_metadata = await asyncio.to_thread(source.get_metadata) or None
|
|
479
|
+
if not snapshot_metadata:
|
|
480
|
+
logger.warning("snapshot_env: source universe %s has no metadata to carry", source_id)
|
|
481
|
+
except Exception:
|
|
482
|
+
if explicit:
|
|
483
|
+
raise # caller named this id — surface a bad one
|
|
484
|
+
logger.warning("snapshot_env: failed to carry metadata from %s", source_id, exc_info=True)
|
|
485
|
+
|
|
486
|
+
# The stored card describes the deployment's own gateway; an override pointing elsewhere keeps the live probe.
|
|
487
|
+
card_record = deployed if deployed is not None and (gateway_url_of(deployed) or "").rstrip("/") == gateway else None
|
|
488
|
+
sem = asyncio.Semaphore(concurrency)
|
|
489
|
+
|
|
490
|
+
async def export_one(environment_name: str):
|
|
491
|
+
"""Returns EnvironmentArtifact on success, (public_err, log_err) on failure."""
|
|
492
|
+
async with sem:
|
|
493
|
+
# Suffix (.json/.zip) is reported by _export_environment_to_file.
|
|
494
|
+
fd, tmp_path = tempfile.mkstemp(prefix=f"{snapshot_id}-{environment_name}-")
|
|
495
|
+
os.close(fd)
|
|
496
|
+
cleanup_paths = [tmp_path]
|
|
497
|
+
artifact_path = tmp_path
|
|
498
|
+
try:
|
|
499
|
+
try:
|
|
500
|
+
result = await cls._export_environment_to_file(
|
|
501
|
+
gateway, environment_name, tmp_path, export_timeout_seconds,
|
|
502
|
+
deployed=card_record,
|
|
503
|
+
)
|
|
504
|
+
except httpx.HTTPStatusError as e:
|
|
505
|
+
body_snippet = ""
|
|
506
|
+
try:
|
|
507
|
+
body_snippet = (await e.response.aread())[:500].decode(errors="replace")
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
public = f"export-state returned HTTP {e.response.status_code}"
|
|
511
|
+
return public, f"{public} from {environment_name}: {body_snippet!r}"
|
|
512
|
+
except httpx.RequestError as e:
|
|
513
|
+
public = f"export-state request failed ({type(e).__name__})"
|
|
514
|
+
return public, f"{public}: {e}"
|
|
515
|
+
except ValueError as e:
|
|
516
|
+
return str(e), str(e)
|
|
517
|
+
|
|
518
|
+
# Service uploaded the bundle itself — register a FileArtifact
|
|
519
|
+
# pointing at it directly (no download). Derive filename/content_type
|
|
520
|
+
# from the url's last segment (the extension drives load-time parsing),
|
|
521
|
+
# like FileArtifact.put.
|
|
522
|
+
if urlparse(result).scheme:
|
|
523
|
+
from agent_env.artifact.store import get_artifact_store
|
|
524
|
+
|
|
525
|
+
object_url = result
|
|
526
|
+
filename = object_url.rsplit("/", 1)[-1] or f"{environment_name}.zip"
|
|
527
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/zip"
|
|
528
|
+
|
|
529
|
+
def _register_uploaded_bundle():
|
|
530
|
+
if get_config().get_object_store().get_object_metadata_at(object_url) is None:
|
|
531
|
+
raise FileNotFoundError(f"snapshot bundle not found at {object_url}")
|
|
532
|
+
store = get_artifact_store()
|
|
533
|
+
fa_id = f"{snapshot_id}-{environment_name}-file"
|
|
534
|
+
get_config().check_object_url(fa_id, object_url)
|
|
535
|
+
fa = FileArtifact(
|
|
536
|
+
id=fa_id,
|
|
537
|
+
version=store.next_version(fa_id),
|
|
538
|
+
description=f"State snapshot of {environment_name} from env {env.id}",
|
|
539
|
+
filename=filename,
|
|
540
|
+
content_type=content_type,
|
|
541
|
+
s3_url=object_url,
|
|
542
|
+
)
|
|
543
|
+
fa = store.put_document(fa)
|
|
544
|
+
return EnvironmentArtifact.put(
|
|
545
|
+
id=f"{snapshot_id}-{environment_name}",
|
|
546
|
+
environment_name=environment_name,
|
|
547
|
+
file_artifact=fa,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
return await asyncio.to_thread(_register_uploaded_bundle)
|
|
552
|
+
except Exception as e:
|
|
553
|
+
public = f"failed to register the uploaded bundle ({type(e).__name__})"
|
|
554
|
+
return public, f"{public} for {environment_name}: {e}"
|
|
555
|
+
|
|
556
|
+
# Otherwise `result` is the written suffix (.json/.zip).
|
|
557
|
+
# FileArtifact.filename = basename(path); a .zip name routes the
|
|
558
|
+
# load side to reset_data() (lossless).
|
|
559
|
+
if result and not tmp_path.endswith(result):
|
|
560
|
+
artifact_path = tmp_path + result
|
|
561
|
+
os.rename(tmp_path, artifact_path)
|
|
562
|
+
cleanup_paths.append(artifact_path)
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
file_artifact = await asyncio.to_thread(
|
|
566
|
+
FileArtifact.put,
|
|
567
|
+
id=f"{snapshot_id}-{environment_name}-file",
|
|
568
|
+
description=f"State snapshot of {environment_name} from env {env.id}",
|
|
569
|
+
file_path=artifact_path,
|
|
570
|
+
)
|
|
571
|
+
return await asyncio.to_thread(
|
|
572
|
+
EnvironmentArtifact.put,
|
|
573
|
+
id=f"{snapshot_id}-{environment_name}",
|
|
574
|
+
environment_name=environment_name,
|
|
575
|
+
file_artifact=file_artifact,
|
|
576
|
+
)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
public = f"failed to create artifacts ({type(e).__name__})"
|
|
579
|
+
return public, f"{public}: {e}"
|
|
580
|
+
finally:
|
|
581
|
+
for path in cleanup_paths:
|
|
582
|
+
try:
|
|
583
|
+
os.unlink(path)
|
|
584
|
+
except OSError:
|
|
585
|
+
pass
|
|
586
|
+
|
|
587
|
+
results = await asyncio.gather(*(export_one(name) for name in services))
|
|
588
|
+
|
|
589
|
+
environment_artifacts = []
|
|
590
|
+
skipped: dict[str, tuple[str, str]] = {}
|
|
591
|
+
for name, result in zip(services, results):
|
|
592
|
+
if isinstance(result, tuple):
|
|
593
|
+
skipped[name] = result
|
|
594
|
+
else:
|
|
595
|
+
environment_artifacts.append(result)
|
|
596
|
+
|
|
597
|
+
if skipped:
|
|
598
|
+
details = "; ".join(f"{name}: {log}" for name, (_, log) in skipped.items())
|
|
599
|
+
logger.error(
|
|
600
|
+
f"snapshot_env: skipped {len(skipped)}/{len(services)} services for "
|
|
601
|
+
f"{env.id} (snapshot_id={snapshot_id}): {details}"
|
|
602
|
+
)
|
|
603
|
+
public_summary = "; ".join(
|
|
604
|
+
f"{name}: {public}" for name, (public, _) in skipped.items()
|
|
605
|
+
)
|
|
606
|
+
raise RuntimeError(
|
|
607
|
+
f"{len(skipped)}/{len(services)} services were not exported "
|
|
608
|
+
f"(missing={sorted(skipped)}): {public_summary}"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
universe = await asyncio.to_thread(
|
|
612
|
+
EnvironmentUniverseArtifact.put,
|
|
613
|
+
id=snapshot_id,
|
|
614
|
+
environment_artifacts=environment_artifacts,
|
|
615
|
+
metadata=snapshot_metadata,
|
|
616
|
+
)
|
|
617
|
+
logger.info(
|
|
618
|
+
f"snapshot_env: created {universe.id} v{universe.version} "
|
|
619
|
+
f"({len(environment_artifacts)} services)"
|
|
620
|
+
)
|
|
621
|
+
return EnvSnapshotResult(
|
|
622
|
+
environment_universe_artifact_id=universe.id,
|
|
623
|
+
environment_universe_artifact_version=universe.version,
|
|
624
|
+
environments_snapshotted=[sa.environment_name for sa in environment_artifacts],
|
|
625
|
+
total=len(services),
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _trajectory_route(deployed, override_url: Optional[str]) -> tuple[str, str]:
|
|
630
|
+
"""The verb and URL of the env trajectory: an override keeps `{override}/trajectory`, else the stored card names them."""
|
|
631
|
+
if override_url:
|
|
632
|
+
return "GET", f"{override_url.rstrip('/')}/trajectory"
|
|
633
|
+
from agentenv_protocol import client as protocol_v1
|
|
634
|
+
|
|
635
|
+
deployed.require(EXT_TRAJECTORY_URI, "get")
|
|
636
|
+
op = protocol_v1.find_extension_method(deployed.environment_card, EXT_TRAJECTORY_URI, "get")
|
|
637
|
+
return op["method"], f"{deployed.environment_url}{op['endpoint']}"
|