From 09e985a9c512c0e8ca892bca4ca9e880af5d3631 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 20:39:36 -0700 Subject: [PATCH 1/6] feat(bundle): write environment and environment_universe artifacts from their artifact folders An environment's artifact.toml names its environment_name and either the file artifact to wrap (file, a bundle file artifact or a store one) or nothing, to wrap the folder's one file, written as __file. A universe's folder is laid out as environment-universe get --output-dir writes one: each / folder holds that environment's file, written as __ over ____file, and each metadata// folder a metadata file, written as __metadata__; environment_artifacts adds environments by name or store id. The stored documents' own key names are refused with a hint, and a universe with no environments, or two of one name, is refused before any write. The ledger tracks both: each base artifact type's listing in plan.py says what its write reads, and every kind but tasks and evals now hashes the versions of the bundle writes it names, so a universe is rewritten when an environment it pins is. No recorded digest changes (a test pins them), so SCHEME stays 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/environment.py | 45 ++- .../artifacts/environment_universe.py | 56 ++- src/agent_env/artifact/artifacts/file.py | 20 +- src/agent_env/bundle/authoring.py | 137 ++++++- src/agent_env/bundle/ledger.py | 21 +- src/agent_env/bundle/plan.py | 75 +++- .../cli/run_bundle_built_envs_test.py | 101 ++++- tst/unit/bundle/artifact_toml_test.py | 365 ++++++++++++++++++ tst/unit/bundle/ledger_digest_test.py | 58 +++ tst/unit/bundle/materialize_test.py | 43 ++- tst/unit/bundle/toml_refs_test.py | 43 ++- 11 files changed, 904 insertions(+), 60 deletions(-) create mode 100644 tst/unit/bundle/artifact_toml_test.py create mode 100644 tst/unit/bundle/ledger_digest_test.py diff --git a/src/agent_env/artifact/artifacts/environment.py b/src/agent_env/artifact/artifacts/environment.py index 894e7bf4..46375227 100644 --- a/src/agent_env/artifact/artifacts/environment.py +++ b/src/agent_env/artifact/artifacts/environment.py @@ -3,17 +3,20 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional from pydantic import ConfigDict, Field, model_serializer from agent_env.artifact.artifact import Artifact from agent_env.artifact.ref import ArtifactRef +from agent_env.entity_refs import EntityRef +from agent_env.store.ids import derive_id logger = logging.getLogger(__name__) if TYPE_CHECKING: from agent_env.artifact.artifacts.file import FileArtifact + from agent_env.bundle.authoring import AuthoringContext class EnvironmentArtifact(Artifact): @@ -21,6 +24,13 @@ class EnvironmentArtifact(Artifact): model_config = ConfigDict(populate_by_name=True) + # An artifact.toml names the environment and either the artifact to wrap (file) or nothing, to wrap the folder's + # one file, whose description it may set. The stored document's own names for these aren't taken. + toml_keys: ClassVar[dict[str, type]] = {"environment_name": str, "file": object, "description": str} + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("file", artifact_type="file"),) + toml_stored_names: ClassVar[dict[str, str]] = {"service_name": "environment_name", "file_artifact_id": "file", + "file_artifact_ref": "file"} + type: Literal["environment"] = "environment" environment_name: str = Field(alias="service_name", description="Name of the environment this artifact belongs to") file_artifact_ref: Optional[ArtifactRef] = Field( @@ -70,6 +80,39 @@ def put( ) return get_artifact_store().put_document(instance) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> EnvironmentArtifact: + """Write the environment authored as ``data`` (its artifact.toml, with ``file`` resolved to a file artifact's + id) under ``ctx.id`` and return it: over the artifact ``file`` names, or over the folder's one file, written + as ``__file`` with ``description`` defaulting to its name.""" + from agent_env.artifact.artifacts.file import FileArtifact + + fields = cls.accept_toml(data, ctx) + if "file" in fields: + file = ctx.artifact(fields["file"], FileArtifact) + else: + filename, path = ctx.file() + file = FileArtifact.put_attempt(derive_id(ctx.id, "file"), description=fields.get("description", filename), + file_path=str(path), filename=filename) + return cls.put(ctx.id, environment_name=fields["environment_name"], file_artifact=file) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an artifact.toml, an environment takes. Raises BundleError listing every problem.""" + data, problems = ctx.renamed(data, cls.toml_stored_names) + fields, more = ctx.accepted(data, **cls.toml_keys) + problems += more + if "environment_name" not in data: + problems.append(ctx.config_problem("environment_name is required: the environment the file seeds")) + elif fields.get("environment_name") == "": + problems.append(ctx.config_problem("environment_name can't be empty")) + if "file" in fields and "description" in fields: + problems.append(ctx.config_problem("description describes the folder's own file, and file names an " + "artifact instead; drop one")) + if problems: + ctx.refuse(problems) + return fields + def get_file_artifact(self) -> FileArtifact: from agent_env.artifact.artifacts.file import FileArtifact diff --git a/src/agent_env/artifact/artifacts/environment_universe.py b/src/agent_env/artifact/artifacts/environment_universe.py index 813eac6e..2f67fff7 100644 --- a/src/agent_env/artifact/artifacts/environment_universe.py +++ b/src/agent_env/artifact/artifacts/environment_universe.py @@ -3,18 +3,21 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Literal, Optional +from collections import Counter +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional from pydantic import AliasChoices, ConfigDict, Field, model_serializer from agent_env.artifact.ref import ArtifactRef from agent_env.artifact.universe import Universe +from agent_env.entity_refs import EntityRef logger = logging.getLogger(__name__) if TYPE_CHECKING: from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.artifacts.environment import EnvironmentArtifact + from agent_env.bundle.authoring import AuthoringContext class EnvironmentUniverseArtifact(Universe): @@ -22,6 +25,16 @@ class EnvironmentUniverseArtifact(Universe): model_config = ConfigDict(populate_by_name=True) + # An artifact.toml may name environment artifacts to add to the ones its folder holds (see from_toml). The stored + # document's own names aren't taken, nor is metadata, which a metadata// folder holds. + toml_keys: ClassVar[dict[str, type]] = {"environment_artifacts": list} + toml_refs: ClassVar[tuple[EntityRef, ...]] = ( + EntityRef.artifact("environment_artifacts[]", artifact_type="environment"),) + toml_stored_names: ClassVar[dict[str, str]] = { + "service_artifact_refs": "environment_artifacts", "environment_artifact_refs": "environment_artifacts", + "service_artifact_ids": "environment_artifacts", "metadata": "a metadata// folder holding its file", + "metadata_refs": "a metadata// folder holding its file"} + type: Literal["environment_universe"] = "environment_universe" # serialization_alias pins the emitted key: the attribute is renamed, the document is not. environment_artifact_refs: Optional[list[ArtifactRef]] = Field( @@ -96,6 +109,47 @@ def put( ) return get_artifact_store().put_document(instance) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> EnvironmentUniverseArtifact: + """Write the universe authored as ``data`` (its artifact.toml, with ``environment_artifacts`` resolved to + environment artifacts' ids) and its folder under ``ctx.id``, and return it. The folder is laid out as + ``environment-universe get --output-dir`` writes one: each ``/`` folder's file is written as + an environment of that name, ``__`` over ``____file``, and each ``metadata//`` + folder's file as ``__metadata__``. Its environments are the folders', in name order, then the ones + ``environment_artifacts`` names. Nothing is written when it has none, or two of one name.""" + from agent_env.artifact.artifacts.environment import EnvironmentArtifact + from agent_env.artifact.artifacts.file import FileArtifact + + fields = cls.accept_toml(data, ctx) + layout = ctx.universe() + named = [ctx.artifact(ref, EnvironmentArtifact) for ref in fields.get("environment_artifacts", [])] + names = Counter([laid.name for laid in layout.environments] + [env.environment_name for env in named]) + if not names: + ctx.refuse([ctx.config_problem("a universe needs an environment: a folder of its own, named after it and " + "holding its file, or one environment_artifacts names")]) + if repeated := sorted(name for name, count in names.items() if count > 1): + ctx.refuse([ctx.config_problem(f"two of its environments are named {name!r}; a universe's environments " + "need names of their own") for name in repeated]) + environments = [] + for laid in layout.environments: + file = FileArtifact.put_attempt(laid.file_id, description=laid.filename, file_path=str(laid.path), + filename=laid.filename) + environments.append(EnvironmentArtifact.put(laid.environment_id, environment_name=laid.name, + file_artifact=file)) + metadata = {laid.name: FileArtifact.put_attempt(laid.file_id, description=laid.filename, + file_path=str(laid.path), filename=laid.filename) + for laid in layout.metadata} + return cls.put(ctx.id, environment_artifacts=environments + named, metadata=metadata or None) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an artifact.toml, a universe takes. Raises BundleError listing every problem.""" + data, problems = ctx.renamed(data, cls.toml_stored_names) + fields, more = ctx.accepted(data, **cls.toml_keys) + if problems + more: + ctx.refuse(problems + more) + return fields + def get_environment_artifacts(self) -> list[EnvironmentArtifact]: from agent_env.artifact.artifacts.environment import EnvironmentArtifact diff --git a/src/agent_env/artifact/artifacts/file.py b/src/agent_env/artifact/artifacts/file.py index 43698b8b..b5b104e7 100644 --- a/src/agent_env/artifact/artifacts/file.py +++ b/src/agent_env/artifact/artifacts/file.py @@ -120,15 +120,21 @@ def put_bytes( @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> Self: - """Write the folder's one file under a prefix of its own (``ArtifactStore.attempt_prefix``); - ``description`` defaults to the file's name.""" - from agent_env.artifact.store import get_artifact_store - + """Write the folder's one file (``put_attempt``); ``description`` defaults to the file's name.""" fields = ctx.accept(data, **cls.toml_keys) filename, path = ctx.file() - prefix = get_artifact_store().attempt_prefix(cls.model_fields["type"].default, ctx.id) - return cls.put_at(ctx.id, description=fields.get("description", filename), file_path=str(path), - object_url=prefix + filename, filename=filename) + return cls.put_attempt(ctx.id, description=fields.get("description", filename), file_path=str(path), + filename=filename) + + @classmethod + def put_attempt(cls, id: str, *, description: str, file_path: str, filename: str) -> Self: + """``put_at`` under a prefix of this attempt's own (``ArtifactStore.attempt_prefix``), so a write that + fails partway never blocks the next one.""" + from agent_env.artifact.store import get_artifact_store + + prefix = get_artifact_store().attempt_prefix(cls.model_fields["type"].default, id) + return cls.put_at(id, description=description, file_path=file_path, object_url=prefix + filename, + filename=filename) def load(self) -> bytes: from agent_env.artifact.store import get_artifact_store diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index d70a2efd..f6424d91 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -7,6 +7,7 @@ import unicodedata from dataclasses import dataclass from pathlib import Path +from collections.abc import Mapping from typing import Any, NoReturn, TypeVar, overload from agent_env.artifact.artifact import Artifact @@ -16,6 +17,7 @@ from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider from agent_env.providers.env_providers.env_provider import _env_provider_class +from agent_env.store.ids import derive_id, validate_local_id from agent_env.utils.card_naming import card_names_in_files from ._fs import os_reason, relative, show, with_article @@ -61,6 +63,16 @@ def files(self) -> dict[str, Path]: def file(self) -> tuple[str, Path]: return entry_file(self.bundle, self.entry) + def universe(self) -> UniverseLayout: + return universe_layout(self.bundle, self.entry) + + def renamed(self, data: dict, names: Mapping[str, str]) -> tuple[dict, list[str]]: + """``data`` less each key ``names`` maps to the key an author writes instead, and a problem for each: + the names a stored document uses, which a toml doesn't.""" + problems = [self.config_problem(f"{key} is what the stored document calls it; write {names[key]} instead") + for key in data if key in names] + return {key: value for key, value in data.items() if key not in names}, problems + def card_names(self, image_key: str) -> list[str] | None: """The names ``@environment_card(name=...)`` gives in the source of the image ``image_key`` builds from this folder: the ``.py`` files of its build context, those in its Dockerfile's folder first. None when the @@ -199,11 +211,12 @@ def _fail(self, message: str) -> NoReturn: self.refuse([message]) -def entry_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: +def entry_files(bundle: Bundle, entry: BundleEntry, *, empty_ok: bool = False) -> dict[str, Path]: """The files in an entry's folder, keyed by POSIX path in NFC and sorted: regular files, dot files included, through links that stay inside the bundle, less what the OS or Python leaves behind and - the entry's own toml. Raises BundleError listing every problem found.""" - return _Walk(bundle, entry).files() + the entry's own toml. Raises BundleError listing every problem found, a folder with no files among + them unless ``empty_ok``.""" + return _Walk(bundle, entry, empty_ok).files() def build_context_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: @@ -218,22 +231,126 @@ def build_context_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: def entry_file(bundle: Bundle, entry: BundleEntry) -> tuple[str, Path]: """The one file in an entry's folder, as ``(name, path)``. Raises BundleError otherwise.""" + key, path = next(iter(one_file(bundle, entry).items())) + return key.rsplit("/", 1)[-1], path + + +_AS_UNIVERSE = "leave out its declared type to write the folder as a file_artifact_universe" + + +def one_file(bundle: Bundle, entry: BundleEntry, remedy: str = _AS_UNIVERSE) -> dict[str, Path]: + """``entry_files`` of a folder that must hold one file. Raises BundleError otherwise, suggesting ``remedy``.""" files = entry_files(bundle, entry) if len(files) != 1: listed = ", ".join(repr(name) for name in files) - raise BundleError([f"{relative(bundle.root, entry.path)}: a {entry.type} {entry.kind.store} holds one file, " - f"and this folder has {len(files)} ({listed}); leave out its declared type to write the " - "folder as a file_artifact_universe"]) - key, path = next(iter(files.items())) - return key.rsplit("/", 1)[-1], path + raise BundleError([f"{relative(bundle.root, entry.path)}: {with_article(f'{entry.type} {entry.kind.store}')} " + f"holds one file, and this folder has {len(files)} ({listed}); {remedy}"]) + return files + + +def environment_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: + """What an environment artifact's write reads from its folder: its one file, or none when its artifact.toml's + ``file`` names the artifact to wrap instead. Raises BundleError otherwise.""" + if not (isinstance(entry.config, dict) and "file" in entry.config): + return one_file(bundle, entry, "name the artifact to wrap with file, or give each other file an artifact " + "folder of its own") + files = entry_files(bundle, entry, empty_ok=True) + if files: + listed = ", ".join(repr(name) for name in files) + raise BundleError([f"{relative(bundle.root, entry.path)}: its file is the artifact file names, so the folder " + f"holds only artifact.toml, and it also has {listed}; give the file an artifact folder of " + "its own, or leave out file to wrap the folder's one file"]) + return {} + + +# The folder of a universe's metadata files, as `environment-universe get --output-dir` writes them. +UNIVERSE_METADATA = "metadata" + + +@dataclass(frozen=True) +class LaidOut: + """One file of a universe folder, and the ids it's written under.""" + + name: str # its environment's name, or its metadata key + filename: str + path: Path + file_id: str + environment_id: str | None # None for a metadata file + + +@dataclass(frozen=True) +class UniverseLayout: + """An environment_universe folder, laid out as ``environment-universe get --output-dir`` writes one: each + ``/`` folder holds one environment's file, and each ``metadata//`` folder one metadata + file. ``files`` lists them all, as ``entry_files`` does.""" + + environments: tuple[LaidOut, ...] + metadata: tuple[LaidOut, ...] + files: dict[str, Path] + + +def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: + """The environments and metadata files an environment_universe's folder holds, each with the ids it's + written under: ``__`` over ``____file`` for an environment, and + ``__metadata__`` for a metadata file. Raises BundleError listing every problem.""" + files = entry_files(bundle, entry, empty_ok=True) + where = relative(bundle.root, entry.path) + problems, folders = [], {} + for key in files: + parts = key.split("/") + metadata = parts[0] == UNIVERSE_METADATA + if len(parts) == (3 if metadata else 2): + folders.setdefault((metadata, parts[-2]), []).append(key) + elif len(parts) == 1: + problems.append(f"{where}/{key}: a universe folder holds only artifact.toml and a folder for each " + "environment; move it into /, or give it an artifact folder of its own") + elif metadata: + problems.append(f"{where}/{key}: {UNIVERSE_METADATA}/ holds a folder for each key, with that key's one " + f"file in it ({UNIVERSE_METADATA}//); an environment named " + f"{UNIVERSE_METADATA} goes in an artifact folder of its own, named in " + "environment_artifacts") + else: + problems.append(f"{where}/{key}: an environment's folder holds its one file directly, " + f"{parts[0]}/, with no folders inside") + environments, metadata_files = [], [] + for (metadata, name), keys in sorted(folders.items()): + folder = f"{where}/{UNIVERSE_METADATA}/{name}" if metadata else f"{where}/{name}" + if len(keys) != 1: + listed = ", ".join(repr(key.rsplit("/", 1)[-1]) for key in keys) + problems.append(f"{folder}: holds one file, and has {len(keys)} ({listed})") + continue + if "__" in name: + problems.append(f"{folder}: a name holding __ could clash with the ids derived from it; rename the folder") + continue + environment_id = None if metadata else derive_id(entry.id, name) + if metadata: + file_id = derive_id(derive_id(entry.id, UNIVERSE_METADATA), name) + else: + file_id = derive_id(environment_id, "file") + try: + validate_local_id(file_id) + except ValueError as e: + problems.append(f"{folder}: can't name the artifacts written from it ({e}); rename the folder") + continue + laid = LaidOut(name, keys[0].rsplit("/", 1)[-1], files[keys[0]], file_id, environment_id) + (metadata_files if metadata else environments).append(laid) + if problems: + raise BundleError(problems) + return UniverseLayout(tuple(environments), tuple(metadata_files), files) + + +def universe_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: + """What an environment_universe's write reads from its folder: every file ``universe_layout`` lays out.""" + return universe_layout(bundle, entry).files class _Walk: """Lists an entry's folder, following a link only when its target is inside the bundle.""" - def __init__(self, bundle: Bundle, entry: BundleEntry): + def __init__(self, bundle: Bundle, entry: BundleEntry, empty_ok: bool = False): self.bundle = bundle self.entry = entry + self.empty_ok = empty_ok self.root = _identity(bundle.root) self.problems: list[str] = [] self.found: dict[str, Path] = {} @@ -245,7 +362,7 @@ def files(self) -> dict[str, Path]: self._problem(top, f"{how} {show(os.path.realpath(top))}, outside the bundle; {_BRING_IT_IN}") else: self._walk(top) - if not self.found and not self.problems: + if not self.found and not self.problems and not self.empty_ok: self._problem(top, f"has no files to write ({', '.join(sorted(LEAVINGS))} don't count)") if self.problems: raise BundleError(self.problems) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index ccf3bc09..b1652d81 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -30,7 +30,7 @@ from agent_env.store.local_state import holding_locks from agent_env.task.store import TASKS_COLLECTION -from .authoring import build_context_files, entry_files +from .authoring import build_context_files from .parse import Bundle, BundleKind from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs from .resolve import BuiltImage @@ -121,15 +121,15 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | "files": {}, "needs": {}, "store_refs": {}} if write.kind is BundleKind.ARTIFACT: built = isinstance(write.source, BuiltImage) - for key, path in (build_context_files if built else entry_files)(self._plan.bundle.bundle, - write.source.entry).items(): + listing = build_context_files if built else folder_walk(get_artifact_registry().get(_type(write))) + for key, path in listing(self._plan.bundle.bundle, write.source.entry).items(): inputs["files"][key] = _file_sha256(path) + (_mode(path) if built else "") - elif write.kind in (BundleKind.ENV, BundleKind.AGENT): - # An env's or agent's document records the versions of what it references, so one written anew means - # it must be written again. A task or eval names its references without a version. + if write.kind not in (BundleKind.TASK, BundleKind.EVAL): + # A document records the versions of what it references (an env its images, a universe its + # environments), so one written anew means it must be written again. A task or eval names its + # references without a version. for store, id in write.needs: inputs["needs"][f"{store} {id}"] = str(needs[store, id]) - if write.kind not in (BundleKind.TASK, BundleKind.EVAL): for (kind, id), version in unpinned_store_refs(self._plan, write).items(): inputs["store_refs"][f"{kind} {id}"] = str(version) value = _sha256(_canonical({"scheme": SCHEME, "store": write.kind.store, "id": write.id, "inputs": inputs})) @@ -199,9 +199,10 @@ def materializing(bundle: Bundle, on_wait: Callable[[], None] | None = None) -> def _tracked(write: Write) -> bool: """Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with - a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent or env is made from its - toml alone: its images and envs are references. A built image is made from its build context, - ``build_context_files``; what the build fetches (its base image, packages) isn't an input.""" + a ``from_toml`` of its own, which may read its folder in ways it can't see. An artifact is made from its toml, + the files its type reads from its folder (``folder_walk``) and what it references; an agent or env from its + toml and what it references. A built image is made from its build context, ``build_context_files``; what the + build fetches (its base image, packages) isn't an input.""" if isinstance(write.source, BuiltImage): return True if write.kind is BundleKind.SKILL: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index ae338704..df2235dd 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -16,7 +16,13 @@ from typing import Any from agent_env.a2a_agent import A2AAgent -from agent_env.artifact import Artifact, FileArtifact, FileArtifactUniverse +from agent_env.artifact import ( + Artifact, + EnvironmentArtifact, + EnvironmentUniverseArtifact, + FileArtifact, + FileArtifactUniverse, +) from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.config import get_config from agent_env.entity_refs import EntityKind @@ -41,7 +47,14 @@ from agent_env.task_step.registry import get_task_step_registry from ._fs import fold, relative, with_article -from .authoring import AuthoringContext, entry_file, entry_files +from .authoring import ( + AuthoringContext, + entry_files, + environment_files, + one_file, + universe_files, + universe_layout, +) from .parse import BundleEntry, BundleError, BundleKind from .resolve import BuiltImage, Reference, ResolvedBundle, ResolvedEntry, build_step @@ -50,8 +63,9 @@ _GETTERS = {EntityKind.ENV: Env.get, EntityKind.AGENT: A2AAgent.get, EntityKind.ARTIFACT: Artifact.get, EntityKind.TASK: Task.get} -# The artifact types written from their folder's files, and how each lists them. -_FOLDER_WALKS = {FileArtifact: entry_file, FileArtifactUniverse: entry_files} +# The artifact types written from their folders, and what each one's write reads there, which the ledger hashes. +_FOLDER_WALKS = {FileArtifact: one_file, FileArtifactUniverse: entry_files, EnvironmentArtifact: environment_files, + EnvironmentUniverseArtifact: universe_files} # The env types whose from_toml reads only the toml and what it names, so a type built by one of them is written from # its env.toml and the ledger can list what it's made from. _ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv) @@ -111,7 +125,7 @@ def __init__(self, resolved: ResolvedBundle): self.identified = {entry.entry.id: entry.entry for entry in resolved.entries} self.outputs = {(output.kind.value, output.id): (entry.entry, output) for entry in resolved.entries for output in entry.outputs} - self.accepted: dict[_Key, dict] = {} # each agent's and env's toml, as its type accepted it + self.accepted: dict[_Key, dict] = {} # each toml a type checks, as it accepted it def _add(self, key: _Key, source: ResolvedEntry | BuiltImage, needs: list[_Key]) -> None: """Record a write in bundle order, a built image just before the entry that builds it.""" @@ -127,6 +141,7 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: self._check_files(closure) self._check_env_types(closure) self._check_multi_names(closure, store_latest) + self._check_universes(closure, store_latest) if self.problems: raise BundleError(self.problems) return Plan(self.resolved, self._ordered(closure), tuple(selected_tasks), tuple(selected_evals), store_refs, @@ -141,6 +156,7 @@ def check(self) -> None: self._check_store_refs(everything, stores=False) self._build_tasks(everything) self._check_files(everything) + self._check_universes(everything, None) if self.problems: raise BundleError(self.problems) @@ -312,8 +328,13 @@ def _check_files(self, closure: set[_Key]) -> None: walk(self.resolved.bundle, source.entry) except BundleError as e: self.problems.extend(e.problems) + ctx = AuthoringContext(self.resolved.bundle, source.entry) + accept = getattr(cls, "accept_toml", None) try: - AuthoringContext(self.resolved.bundle, source.entry).accept(source.config, **cls.toml_keys) + if accept is None: + ctx.accept(source.config, **cls.toml_keys) + else: + self.accepted[key] = accept(source.config, ctx) except BundleError as e: self.problems.extend(e.problems) @@ -377,14 +398,50 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity if reason is not None: self.problems.append(f"{where}: env_provider_type {provider_type!r} {reason}; rename one") - def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int]) -> str | None: + def _check_universes(self, closure: set[_Key], store_latest: dict[tuple[EntityKind, str], int] | None) -> None: + """A universe needs an environment, and each of its environments a name of its own: one its folder holds is + named by its folder, one it names by its toml or, from the store, at the version the plan read. Without + ``store_latest`` (a check that reads no store), the store's environments aren't counted.""" + registry = get_artifact_registry() + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ARTIFACT: + continue + cls = registry.get(canonical_type(source.entry.type)) + if cls is None or not issubclass(cls, EnvironmentUniverseArtifact) or folder_walk(cls) is None: + continue + try: + layout = universe_layout(self.resolved.bundle, source.entry) + except BundleError: + continue # reported by the file check + where = self._path(source.entry) + taken = {laid.name: f"the {laid.name}/ folder" for laid in layout.environments} # who has each name + for ref in source.references: + name = self._environment_name(ref, store_latest) + if name is None: + continue + if name in taken: + self.problems.append(f"{where}: {ref.where}: environment_name {name!r} is also {taken[name]}'s; " + "a universe's environments need names of their own") + else: + taken[name] = ref.where + if not layout.environments and not source.references and not source.config.get("environment_artifacts"): + self.problems.append(f"{where}: a universe needs an environment: a folder of its own, named after it " + "and holding its file, or one environment_artifacts names") + + def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> str | None: + """The environment_name of the env or environment artifact ``ref`` names: a bundle one's from its accepted + toml, a store one's at the version the plan read, or None when that's unknown.""" if ref.local is not None: return self.accepted.get(_key(ref.local), {}).get("environment_name") + if store_latest is None: + return None try: - env = Env.get(ref.id, ref.version if ref.version is not None else store_latest.get((ref.kind, ref.id))) + entity = _GETTERS[ref.kind](ref.id, ref.version if ref.version is not None + else store_latest.get((ref.kind, ref.id))) except (NotFoundError, ValueError, KeyError, TypeError): return None # reported by the store ref check - return getattr(env, "environment_name", None) + return getattr(entity, "environment_name", None) def _build_tasks(self, closure: set[_Key]) -> None: """Build each task's steps the way a write will, so a bad step fails before anything is written.""" diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index d11c10d8..f38397fc 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -3,7 +3,8 @@ reused while its folder is unchanged, and rebuilt when it changes. Through the gateway, each way of writing an env deploys and answers a verifier calling it: an MCP server built from its folder, one built from a Dockerfile elsewhere under a name of its own, one over a store image, a website, and a multi of bundle and store envs, which is rewritten -when a child is. Needs a Docker daemon and the local registry.""" +when a child is. Environments and a universe the bundle writes load into an MCP server deployed through the gateway, +and an edit to their files loads anew. Needs a Docker daemon and the local registry.""" import hashlib import json @@ -14,6 +15,7 @@ import pytest from click.testing import CliRunner +from agent_env.artifact import EnvironmentUniverseArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.bundle import parse_bundle from agent_env.cli import cli @@ -277,6 +279,103 @@ def test_each_kind_of_bundle_env_deploys_through_the_gateway_and_a_child_edit_re assert f"envs/{env}: v{versions.get(env, 1)}, unchanged" in planned.output, planned.output +# Checks the items server lists exactly the items CONFIG names, the ones the task loaded. CONFIG goes above it. +VERIFY_LOADED = ''' +import json + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def verify(mcp_url): + async with streamablehttp_client(mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + name = next(tool.name for tool in (await session.list_tools()).tools if tool.name.endswith("list_items")) + result = await session.call_tool(name, {}) + listed = json.loads(" ".join(getattr(part, "text", "") for part in result.content)) + return [{"id": "loaded", "result": listed.get("items") == CONFIG["items"], + "description": f"list_items lists what the task loaded, {CONFIG['items']}: {listed}"}] +''' + + +def _seed(items): + return json.dumps({"items": items}) + "\n" + + +def _write(root, files): + for rel, text in files.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + + +def _load_task(root, name, artifact, items): + """A task deploying the items server, loading ``artifact`` into it and checking it lists ``items``.""" + (root / f"artifacts/check-{name}").mkdir(parents=True, exist_ok=True) + (root / f"artifacts/check-{name}/verify.py").write_text(f"CONFIG = {{'items': {items!r}}}\n{VERIFY_LOADED}") + (root / "tasks").mkdir(exist_ok=True) + (root / f"tasks/{name}.json").write_text(json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": "items", "sandbox_type": "local"}, + {"id": "load", "type": "load_artifact", "env_id": "items", "artifact_id": artifact, "depends_on": ["env"]}, + {"id": "check", "type": "env_outcome_verifier", "env_id": "items", "file_artifact_id": f"check-{name}", + "verifier_id": name, "depends_on": ["load"]}, + ])) + + +def _data_bundle(root): + """The items server, and data for it in each shape a bundle writes: an environment over its folder's file + (seed), one naming a file artifact (by-ref over items-file), and a universe laid out as `environment-universe get + --output-dir` writes one, with the items server's data in its items/ folder, a metadata file, and an environment + for another server (mail-data), which the items server skips.""" + _items(root / "envs/items", "v1") + _write(root / "artifacts", { + "seed/artifact.toml": 'type = "environment"\nenvironment_name = "items"\n', + "seed/items.json": _seed(["folder"]), + "items-file/items.json": _seed(["by-ref"]), + "by-ref/artifact.toml": 'type = "environment"\nenvironment_name = "items"\nfile = "items-file"\n', + "mail-data/artifact.toml": 'type = "environment"\nenvironment_name = "mail"\n', "mail-data/mail.json": "{}\n", + "world/artifact.toml": 'type = "environment_universe"\nenvironment_artifacts = ["mail-data"]\n', + "world/items/items.json": _seed(["universe"]), "world/metadata/manifest/manifest.json": '{"m": 1}\n', + }) + _load_task(root, "folder", "seed", ["folder"]) + _load_task(root, "ref", "by-ref", ["by-ref"]) + _load_task(root, "universe", "world", ["universe"]) + return root + + +DATA = {"seed": "environment", "by-ref": "environment", "items-file": "file", "world": "environment_universe"} + + +def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_through_the_gateway(state): + root = _data_bundle(state / "data-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + for name in DATA: + assert f"artifacts/{name}: v1 (new)" in first.output, first.output + for task in ("folder", "ref", "universe"): + assert f"tasks/{task}.json v1: passed (check: 1)" in first.output, first.output + with namespace_routing(): + ids = {entry.name: entry.id for entry in parse_bundle(root).entries} + world = EnvironmentUniverseArtifact.get(ids["world"]) + assert [env.environment_name for env in world.get_environment_artifacts()] == ["items", "mail"] + assert list(world.get_metadata()) == ["manifest"] + + (root / "artifacts/seed/items.json").write_text(_seed(["folder", "edited"])) + (root / "artifacts/world/items/items.json").write_text(_seed(["universe", "edited"])) + _load_task(root, "folder", "seed", ["folder", "edited"]) + _load_task(root, "universe", "world", ["universe", "edited"]) + edited = _run(root, "--task", "folder", "--task", "universe") + + assert edited.exit_code == 0, edited.output + assert "artifacts/seed: v2 (files changed: items.json)" in edited.output, edited.output + assert "artifacts/world: v2 (files changed: items/items.json)" in edited.output, edited.output + assert "artifacts/mail-data: v1, unchanged" in edited.output and "envs/items: v1, unchanged" in edited.output + for task in ("folder", "universe"): + assert f"tasks/{task}.json v1: passed (check: 1)" in edited.output, edited.output + + def _files(root): """A digest of every file under ``root`` but the locks a run takes.""" digest = hashlib.sha256() diff --git a/tst/unit/bundle/artifact_toml_test.py b/tst/unit/bundle/artifact_toml_test.py new file mode 100644 index 00000000..c6388787 --- /dev/null +++ b/tst/unit/bundle/artifact_toml_test.py @@ -0,0 +1,365 @@ +"""Environment and environment_universe artifacts written from a bundle's artifact folders: an environment over the +file its folder holds or the file artifact it names, and a universe laid out as ``environment-universe get +--output-dir`` writes one, adding the environments it names. Each is checked before any write, rewritten when +what it records changes, and equal, field for field, to one the CLI writes from the same files.""" + +import json + +import pytest +from click.testing import CliRunner + +from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact, FileArtifact +from agent_env.artifact.registry import canonical_type +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import BundleError, parse_bundle +from agent_env.bundle.materialize import materialize +from agent_env.bundle.plan import check_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.cli import cli +from agent_env.store.routing import namespace_routing +from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of + +ROOT = "@local/~/triage" +TOML = "artifact.toml" + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "triage" + + +def _bundle(root, files, *loaded): + """``files`` laid out under ``root``, with a task loading each artifact in ``loaded`` into a local sandbox.""" + steps = [{"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", + "sandbox_type": "local"}] + steps += [{"id": f"load-{name}", "type": "load_artifact", "sandbox_name": "box", "artifact_id": name, + "destination_path": f"/app/{name}", "depends_on": ["box"]} for name in loaded] + return layout(root, {**files, "tasks/t.json": json.dumps(steps)}) + + +def _environment(name, environment_name, **keys): + toml = 'type = "environment"\n' + f'environment_name = "{environment_name}"\n' + toml += "".join(f"{key} = {value}\n" for key, value in keys.items()) + return {f"artifacts/{name}/{TOML}": toml} + + +def _universe(name, **keys): + toml = 'type = "environment_universe"\n' + "".join(f"{key} = {value}\n" for key, value in keys.items()) + return {f"artifacts/{name}/{TOML}": toml} + + +def _run(root): + return materialize(plan_of(root)) + + +def _summary(materialization): + return {done.write.id: (done.version, done.reused, done.reasons) for done in materialization.writes} + + +def _problems(root): + with pytest.raises(BundleError) as caught: + plan_of(root) + return caught.value.problems + + +def _store_file(id, content, filename="data.json"): + with namespace_routing(): + return FileArtifact.put_bytes(id, description=id, filename=filename, content=content, + content_type="application/json") + + +def _canonical(artifact): + """What an artifact means, less what equivalence ignores (ids, versions, created_at_utc, object URLs and a + file's description): its type, after canonical_type, its fields, and what its refs name, followed.""" + if isinstance(artifact, FileArtifact): + return {"type": canonical_type(artifact.type), "filename": artifact.filename, + "content_type": artifact.content_type, "content": artifact.load()} + if isinstance(artifact, EnvironmentArtifact): + return {"type": canonical_type(artifact.type), "environment_name": artifact.environment_name, + "file": _canonical(artifact.get_file_artifact())} + return {"type": canonical_type(artifact.type), + "environments": sorted((_canonical(env) for env in artifact.get_environment_artifacts()), + key=lambda env: env["environment_name"]), + "metadata": {key: _canonical(file) for key, file in artifact.get_metadata().items()}} + + +def _fields(artifact): + """The keys of the document the store holds for ``artifact``.""" + return set(artifact.model_dump(by_alias=True)) + + +# Environments + + +def test_an_environment_wraps_the_one_file_its_folder_holds_and_is_rewritten_when_it_changes(bundle_dir): + _bundle(bundle_dir, {**_environment("crm-data", "crm"), "artifacts/crm-data/data.json": '{"rows": 1}\n'}, + "crm-data") + + first = _summary(_run(bundle_dir)) + environment = EnvironmentArtifact.get(f"{ROOT}/crm-data") + file = environment.get_file_artifact() + + assert first[f"{ROOT}/crm-data"] == (1, False, ("new",)) + assert environment.environment_name == "crm" + assert (file.id, file.version, file.filename, file.description) == (f"{ROOT}/crm-data__file", 1, "data.json", + "data.json") + assert file.load() == b'{"rows": 1}\n' + + assert all(reused for _, reused, _ in _summary(_run(bundle_dir)).values()) + (bundle_dir / "artifacts/crm-data/data.json").write_text('{"rows": 2}\n') + assert _summary(_run(bundle_dir))[f"{ROOT}/crm-data"] == (2, False, ("files changed: data.json",)) + assert EnvironmentArtifact.get(f"{ROOT}/crm-data").get_file_artifact().load() == b'{"rows": 2}\n' + + +def test_a_description_names_the_folders_file(bundle_dir): + _bundle(bundle_dir, {**_environment("crm-data", "crm", description='"crm rows"'), + "artifacts/crm-data/data.json": "{}\n"}, "crm-data") + + _run(bundle_dir) + + assert EnvironmentArtifact.get(f"{ROOT}/crm-data").get_file_artifact().description == "crm rows" + + +def test_an_environment_over_a_bundle_file_is_rewritten_when_the_file_is(bundle_dir): + _bundle(bundle_dir, {"artifacts/crm-file/data.json": "{}\n", **_environment("crm-data", "crm", file='"crm-file"')}, + "crm-data") + + first = _summary(_run(bundle_dir)) + file = EnvironmentArtifact.get(f"{ROOT}/crm-data").get_file_artifact() + + assert first[f"{ROOT}/crm-file"][0] == first[f"{ROOT}/crm-data"][0] == 1 + assert (file.id, file.version) == (f"{ROOT}/crm-file", 1) + (bundle_dir / "artifacts/crm-file/data.json").write_text('{"edited": true}\n') + again = _summary(_run(bundle_dir)) + assert again[f"{ROOT}/crm-data"] == (2, False, (f"artifact {ROOT}/crm-file is written anew (v1 → v2)",)) + assert EnvironmentArtifact.get(f"{ROOT}/crm-data").get_file_artifact().version == 2 + + +def test_an_environment_over_a_store_file_pins_the_version_the_plan_read(bundle_dir): + _store_file("crm-rows", b"{}\n") + _bundle(bundle_dir, {**_environment("crm-data", "crm", file='"crm-rows"'), + **_environment("pinned", "crm", file='{ artifact = "crm-rows", version = 1 }')}, + "crm-data", "pinned") + + _run(bundle_dir) + _store_file("crm-rows", b'{"newer": true}\n') + again = _summary(_run(bundle_dir)) + + assert again[f"{ROOT}/crm-data"] == (2, False, ("artifact crm-rows has a new version in the store (v1 → v2)",)) + assert again[f"{ROOT}/pinned"][1] is True + assert EnvironmentArtifact.get(f"{ROOT}/crm-data").file_artifact_ref.version == 2 + assert EnvironmentArtifact.get(f"{ROOT}/pinned").file_artifact_ref.version == 1 + + +# Universes + + +def test_a_universe_writes_the_environments_and_metadata_its_folder_lays_out_then_the_ones_it_names(bundle_dir): + _bundle(bundle_dir, { + **_environment("crm-data", "crm"), "artifacts/crm-data/data.json": '{"crm": 1}\n', + **_universe("world", environment_artifacts='["crm-data"]'), + "artifacts/world/slack/data.json": '{"slack": 1}\n', "artifacts/world/gmail/mail.zip": "zip\n", + "artifacts/world/metadata/manifest/manifest.json": '{"m": 1}\n', + }, "world") + + first = _summary(_run(bundle_dir)) + world = EnvironmentUniverseArtifact.get(f"{ROOT}/world") + environments = world.get_environment_artifacts() + + assert first[f"{ROOT}/world"] == (1, False, ("new",)) + assert [(env.id, env.environment_name) for env in environments] == [ + (f"{ROOT}/world__gmail", "gmail"), (f"{ROOT}/world__slack", "slack"), (f"{ROOT}/crm-data", "crm")] + assert environments[0].get_file_artifact().id == f"{ROOT}/world__gmail__file" + assert world.get_file_artifacts()["slack/data.json"].load() == b'{"slack": 1}\n' + manifest = world.get_metadata()["manifest"] + assert (manifest.id, manifest.filename, manifest.load()) == (f"{ROOT}/world__metadata__manifest", "manifest.json", + b'{"m": 1}\n') + assert world.legacy_metadata is None and "metadata" not in world.model_dump(by_alias=True) + + assert all(reused for _, reused, _ in _summary(_run(bundle_dir)).values()) + (bundle_dir / "artifacts/crm-data/data.json").write_text('{"crm": 2}\n') + assert _summary(_run(bundle_dir))[f"{ROOT}/world"] == ( + 2, False, (f"artifact {ROOT}/crm-data is written anew (v1 → v2)",)) + (bundle_dir / "artifacts/world/slack/data.json").write_text('{"slack": 2}\n') + assert _summary(_run(bundle_dir))[f"{ROOT}/world"] == (3, False, ("files changed: slack/data.json",)) + world = EnvironmentUniverseArtifact.get(f"{ROOT}/world") + assert world.get_file_artifacts()["slack/data.json"].load() == b'{"slack": 2}\n' + assert EnvironmentArtifact.get(f"{ROOT}/world__slack").version == 3 # the folder's children follow the universe + + +def test_a_universe_of_store_environments_only_needs_no_files(bundle_dir): + with namespace_routing(): + mail = EnvironmentArtifact.put("mail-data", environment_name="gmail", + file_artifact=_store_file("mail-rows", b"{}\n")) + _bundle(bundle_dir, _universe("world", environment_artifacts='["mail-data"]'), "world") + + _run(bundle_dir) + + refs = EnvironmentUniverseArtifact.get(f"{ROOT}/world").environment_artifact_refs + assert [(ref.id, ref.version) for ref in refs] == [("mail-data", mail.version)] + + +def test_a_universe_over_store_data_loads_it_through_the_local_namespace(bundle_dir): + """An @local universe whose environment wraps a store file: its files read through the routed stores.""" + _store_file("crm-rows", b'{"from": "store"}\n') + _bundle(bundle_dir, {**_environment("crm-data", "crm", file='"crm-rows"'), + **_universe("world", environment_artifacts='["crm-data"]')}, "world") + + _run(bundle_dir) + + with namespace_routing(): + files = EnvironmentUniverseArtifact.get(f"{ROOT}/world").get_file_artifacts() + assert {name: file.load() for name, file in files.items()} == {"crm/data.json": b'{"from": "store"}\n'} + + +def test_a_universe_the_cli_downloads_is_written_from_a_bundle_equal_to_its_original(bundle_dir, tmp_path): + for name, content in (("gmail", b'{"inbox": []}\n'), ("slack", b'{"channels": []}\n')): + (tmp_path / f"{name}.json").write_bytes(content) + put = CliRunner().invoke(cli, ["artifact", "environment", "put", str(tmp_path / f"{name}.json"), + "--id", f"{name}-data", "--description", name, "--environment-name", name]) + assert put.exit_code == 0, put.output + (tmp_path / "manifest.json").write_text('{"m": 1}\n') + put = CliRunner().invoke(cli, ["artifact", "environment-universe", "put", "--id", "original", + "--environment-artifact", "gmail-data", "--environment-artifact", "slack-data", + "--metadata", f"manifest={tmp_path / 'manifest.json'}"]) + assert put.exit_code == 0, put.output + folder = bundle_dir / "artifacts/copy" + got = CliRunner().invoke(cli, ["artifact", "environment-universe", "get", "--id", "original", "--output-dir", + str(folder)]) + assert got.exit_code == 0, got.output + _bundle(bundle_dir, _universe("copy"), "copy") + + _run(bundle_dir) + + with namespace_routing(): + original, copy = EnvironmentUniverseArtifact.get("original"), EnvironmentUniverseArtifact.get(f"{ROOT}/copy") + assert _canonical(copy) == _canonical(original) + assert _fields(copy) == _fields(original) + for written, cli_written in zip(copy.get_environment_artifacts(), original.get_environment_artifacts()): + assert _fields(written) == _fields(cli_written) + assert _fields(written.get_file_artifact()) == _fields(cli_written.get_file_artifact()) + + +def test_an_environment_written_either_way_equals_the_one_the_cli_writes(bundle_dir, tmp_path): + (tmp_path / "data.json").write_text('{"rows": 1}\n') + put = CliRunner().invoke(cli, ["artifact", "environment", "put", str(tmp_path / "data.json"), "--id", "original", + "--description", "crm", "--environment-name", "crm"]) + assert put.exit_code == 0, put.output + _bundle(bundle_dir, {**_environment("folder", "crm"), "artifacts/folder/data.json": '{"rows": 1}\n', + "artifacts/crm-file/data.json": '{"rows": 1}\n', + **_environment("named", "crm", file='"crm-file"')}, "folder", "named") + + _run(bundle_dir) + + with namespace_routing(): + original = EnvironmentArtifact.get("original") + for name in ("folder", "named"): + written = EnvironmentArtifact.get(f"{ROOT}/{name}") + assert _canonical(written) == _canonical(original) + assert _fields(written) == _fields(original) + + +# Refused before any write + + +def _refused(files): + return {**files, "tasks/t.json": json.dumps([ + {"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", "sandbox_type": "local"}, + *({"id": f"load-{name}", "type": "load_artifact", "sandbox_name": "box", "artifact_id": name, + "destination_path": f"/app/{name}", "depends_on": ["box"]} for name in ("x",)), + ])} + + +@pytest.mark.parametrize("files, problem", [ + (_environment("x", "crm") | {"artifacts/x/a.json": "{}", "artifacts/x/b.json": "{}"}, + "artifacts/x: an environment artifact holds one file, and this folder has 2 ('a.json', 'b.json'); name the " + "artifact to wrap with file, or give each other file an artifact folder of its own"), + ({f"artifacts/x/{TOML}": 'type = "environment"\n', "artifacts/x/a.json": "{}"}, + "artifacts/x: artifact.toml: environment_name is required: the environment the file seeds"), + (_environment("x", "") | {"artifacts/x/a.json": "{}"}, + "artifacts/x: artifact.toml: environment_name can't be empty"), + (_environment("x", "crm", service_name='"crm"') | {"artifacts/x/a.json": "{}"}, + "artifacts/x: artifact.toml: service_name is what the stored document calls it; write environment_name instead"), + (_environment("x", "crm", file_artifact_id='"f"') | {"artifacts/x/a.json": "{}"}, + "artifacts/x: artifact.toml: file_artifact_id is what the stored document calls it; write file instead"), + (_environment("x", "crm", file='"f"', description='"d"') | {"artifacts/f/a.json": "{}"}, + "artifacts/x: artifact.toml: description describes the folder's own file, and file names an artifact instead; " + "drop one"), + (_environment("x", "crm", file='"f"') | {"artifacts/f/a.json": "{}", "artifacts/x/stray.json": "{}"}, + "artifacts/x: its file is the artifact file names, so the folder holds only artifact.toml, and it also has " + "'stray.json'; give the file an artifact folder of its own, or leave out file to wrap the folder's one file"), + (_environment("x", "crm", file='"many"') | {"artifacts/many/a.json": "{}", "artifacts/many/b.json": "{}"}, + "artifacts/x: file: 'many' is this bundle's file_artifact_universe, but this field takes file"), + (_universe("x"), + "artifacts/x: a universe needs an environment: a folder of its own, named after it and holding its file, or one " + "environment_artifacts names"), + (_universe("x") | {"artifacts/x/stray.json": "{}"}, + "artifacts/x/stray.json: a universe folder holds only artifact.toml and a folder for each environment; move it " + "into /, or give it an artifact folder of its own"), + (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/gmail/b.json": "{}"}, + "artifacts/x/gmail: holds one file, and has 2 ('a.json', 'b.json')"), + (_universe("x") | {"artifacts/x/gmail/inner/a.json": "{}"}, + "artifacts/x/gmail/inner/a.json: an environment's folder holds its one file directly, gmail/, with no " + "folders inside"), + (_universe("x") | {"artifacts/x/g__mail/a.json": "{}"}, + "artifacts/x/g__mail: a name holding __ could clash with the ids derived from it; rename the folder"), + (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/metadata/m.json": "{}"}, + "artifacts/x/metadata/m.json: metadata/ holds a folder for each key, with that key's one file in it " + "(metadata//); an environment named metadata goes in an artifact folder of its own, named in " + "environment_artifacts"), + (_universe("x", environment_artifacts='["gmail-data"]') | {"artifacts/x/gmail/a.json": "{}"} + | _environment("gmail-data", "gmail") | {"artifacts/gmail-data/a.json": "{}"}, + "artifacts/x: environment_artifacts[0]: environment_name 'gmail' is also the gmail/ folder's; a universe's " + "environments need names of their own"), + (_universe("x", service_artifact_refs='["a"]', metadata='{ m = "f" }') | {"artifacts/x/gmail/a.json": "{}"}, + "artifacts/x: artifact.toml: service_artifact_refs is what the stored document calls it; write " + "environment_artifacts instead"), +], ids=["environment-two-files", "environment-no-name", "environment-empty-name", "environment-service-name", + "environment-file-artifact-id", "environment-file-and-description", "environment-file-and-a-file", + "environment-over-a-universe", "universe-empty", "universe-root-file", "universe-two-files", + "universe-nested-folder", "universe-double-underscore", "universe-flat-metadata", "universe-repeated-name", + "universe-stored-names"]) +def test_an_artifact_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): + layout(bundle_dir, _refused(files)) + + assert problem in _problems(bundle_dir) + assert not local_store().path.exists() + + +def test_a_universe_naming_a_store_environment_with_the_name_of_one_of_its_folders_is_refused(bundle_dir): + with namespace_routing(): + EnvironmentArtifact.put("mail-data", environment_name="gmail", file_artifact=_store_file("mail-rows", b"{}")) + _bundle(bundle_dir, {**_universe("world", environment_artifacts='["mail-data"]'), + "artifacts/world/gmail/a.json": "{}"}, "world") + + assert _problems(bundle_dir) == ("artifacts/world: environment_artifacts[0]: environment_name 'gmail' is also the " + "gmail/ folder's; a universe's environments need names of their own",) + + +def test_a_check_reports_artifact_toml_problems_without_reading_a_store(bundle_dir, monkeypatch): + _bundle(bundle_dir, {**_universe("world"), "artifacts/world/stray.json": "{}", + f"artifacts/crm/{TOML}": 'type = "environment"\n', "artifacts/crm/a.json": "{}"}, + "world", "crm") + monkeypatch.setattr("agent_env.config.runtime.Config.get_document_store", lambda self: RefusingStore()) + + with pytest.raises(BundleError) as caught: + check_bundle(resolve_bundle(parse_bundle(bundle_dir))) + + assert sorted(caught.value.problems) == [ + "artifacts/crm: artifact.toml: environment_name is required: the environment the file seeds", + "artifacts/world/stray.json: a universe folder holds only artifact.toml and a folder for each environment; " + "move it into /, or give it an artifact folder of its own", + ] + + +def test_a_universe_written_by_hand_in_the_store_isnt_touched_by_a_bundle_naming_it(bundle_dir): + with namespace_routing(): + mail = EnvironmentArtifact.put("mail-data", environment_name="gmail", file_artifact=_store_file("m", b"{}")) + before = get_artifact_store().get("mail-data").version + _bundle(bundle_dir, _universe("world", environment_artifacts='["mail-data"]'), "world") + + _run(bundle_dir) + + with namespace_routing(): + assert get_artifact_store().get("mail-data").version == before == mail.version diff --git a/tst/unit/bundle/ledger_digest_test.py b/tst/unit/bundle/ledger_digest_test.py new file mode 100644 index 00000000..83bed8dd --- /dev/null +++ b/tst/unit/bundle/ledger_digest_test.py @@ -0,0 +1,58 @@ +"""The digests the ledger records stay byte-identical across releases unless its SCHEME changes: a bundle run on a +new release reuses every version an older one wrote. Each value here was recorded by an earlier release.""" + +import json + +import pytest + +from agent_env.bundle.ledger import Ledger +from tst.unit.bundle._support import layout, plan_of + +LAYOUT = { + "artifacts/greeting/hello.txt": "hello\n", + "artifacts/shelf/a.txt": "a\n", + "artifacts/shelf/sub/b.txt": "b\n", + "artifacts/note/artifact.toml": 'description = "a note"\n', + "artifacts/note/note.md": "# note\n", + "envs/tickets/Dockerfile": "FROM scratch\nCOPY . /app\n", + "envs/tickets/server.py": "print('tickets')\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', + "agents/solver/Dockerfile": "FROM scratch\n", + "agents/solver/agent.py": "print('solver')\n", + "tasks/t.json": json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": "tickets"}, + {"id": "load", "type": "load_artifact", "env_id": "tickets", "artifact_id": "greeting", "depends_on": ["env"]}, + ]), + "tasks/u.json": json.dumps([ + {"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}, + {"id": "shelf", "type": "load_artifact", "env_id": "tickets", "artifact_id": "shelf"}, + {"id": "note", "type": "load_artifact", "env_id": "tickets", "artifact_id": "note"}, + ]), +} + +# Recorded by agentenv-framework 0.9.1278. +RECORDED = { + "@local/~/triage/tickets__env_image": "sha256:b381bf1c841f024fe2dfeabb08e5a10cf2b3cc596563ba3e6ea5c6798af1c868", + "@local/~/triage/tickets": "sha256:724cfb95eed4f78c7f77dc1c94507277217860bcce6a773bc3156e77bc2f8521", + "@local/~/triage/solver__agent_image": "sha256:00fb2164bc6e1d8230ef6e2d437f640ba853acde7ab7c4f13485d3fb1b48d5d6", + "@local/~/triage/solver": "sha256:54bcabc92b285434ad23af91e2dcf1b04700d73a819016b1e62b66f7d0f3ec0e", + "@local/~/triage/greeting": "sha256:e0047a71d6e34c6f390f6b6bf416ff54765bbbc390b2bd7f34432ee79a691e66", + "@local/~/triage/note": "sha256:3e9de21964b7c4e78a240f8f7aaafb66a0f04a9a430b33b6e54b9869c9001a9d", + "@local/~/triage/shelf": "sha256:ab0dd7c0b9c41312caeb9e012a31e4b640d0c626ddf005a5d7607824f36aa674", + "@local/~/triage/t": "sha256:056ad6e1030f251c2a6f9e144270fd53d9053c5d4264e0654ba8e547b10abe7a", + "@local/~/triage/u": "sha256:f8d5445a95fed8bf4d03b713fa8f545b1a8da847db02c22a3806eec39e26db29", +} + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + return layout(tmp_path / "triage", LAYOUT) + + +def test_every_digest_an_earlier_release_recorded_is_unchanged(bundle_dir): + plan = plan_of(bundle_dir) + ledger = Ledger.for_plan(plan) + digests = {write.id: ledger.digest(write, {need: 1 for need in write.needs}) for write in plan.writes} + + assert {id: digest.value for id, digest in digests.items() if digest is not None} == RECORDED diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index e5d895d8..39cfe381 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -12,7 +12,7 @@ import pytest from agent_env.a2a_agent import A2AAgent -from agent_env.artifact import Artifact +from agent_env.artifact import Artifact, EnvironmentArtifact, EnvironmentUniverseArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse @@ -109,6 +109,26 @@ def from_toml(cls, data, ctx): raise NotImplementedError +class _OwnEnvironment(EnvironmentArtifact): + """A plugin's environment type with a from_toml of its own.""" + + type: Literal["own_environment_materialize_test"] = "own_environment_materialize_test" + + @classmethod + def from_toml(cls, data, ctx): + raise NotImplementedError + + +class _OwnUniverse(EnvironmentUniverseArtifact): + """A plugin's universe type with a from_toml of its own.""" + + type: Literal["own_universe_materialize_test"] = "own_universe_materialize_test" + + @classmethod + def from_toml(cls, data, ctx): + raise NotImplementedError + + class _OwnEnv(Env): """A plugin's env type with a from_toml of its own.""" @@ -123,7 +143,7 @@ def from_toml(cls, data, ctx): def registries(monkeypatch): steps = {**Config().task_step_registry(), **{cls.type: cls for cls in (_Checked, _Writes, _Consuming)}} envs = {**Config().env_registry(), _OwnEnv.type: _OwnEnv} - plugins = {cls.model_fields["type"].default: cls for cls in (_PluginFile, _OwnFile)} + plugins = {cls.model_fields["type"].default: cls for cls in (_PluginFile, _OwnFile, _OwnEnvironment, _OwnUniverse)} artifacts = {**Config().artifact_registry(), **plugins} monkeypatch.setattr(Config, "task_step_registry", lambda self: steps) monkeypatch.setattr(Config, "env_registry", lambda self: envs) @@ -194,7 +214,7 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir "agents/solver/Dockerfile": "FROM scratch\n", "skills/pdf/SKILL.md": "---\nname: pdf\n---\n", "artifacts/base-mcp/Dockerfile": "FROM scratch\n", - "artifacts/snap/artifact.toml": 'type = "environment"\n', + "artifacts/vm/artifact.toml": 'type = "vm_image"\nimage_name = "tahoe"\n', "evals/regression.toml": 'tasks = ["t"]\n', }) _steps(bundle_dir, [ @@ -203,12 +223,12 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}, {"id": "pdf", "type": "load_artifact", "env_id": "tickets", "artifact_id": "pdf"}, {"id": "image", "type": "load_artifact", "env_id": "tickets", "artifact_id": "base-mcp"}, - {"id": "snap", "type": "load_artifact", "env_id": "tickets", "artifact_id": "snap"}, + {"id": "vm", "type": "load_artifact", "env_id": "tickets", "artifact_id": "vm"}, ]) assert sorted(_problems(lambda: _run(bundle_dir, dry_run))) == [ "artifacts/base-mcp: writing a docker_image artifact isn't supported yet", - "artifacts/snap: writing an environment artifact isn't supported yet", + "artifacts/vm: writing a vm_image artifact isn't supported yet", "envs/own: writing an own_env_materialize_test env isn't supported yet", "skills/pdf: writing a skill isn't supported yet", ] @@ -491,12 +511,15 @@ def test_a_plugin_file_type_is_written_when_it_writes_as_file_artifact_does(bund assert _summary(_run(bundle_dir))[f"{ROOT}/greeting"] == (1, True, ()) -def test_a_plugin_type_with_a_from_toml_of_its_own_is_refused(bundle_dir): - (bundle_dir / "artifacts/greeting/artifact.toml").write_text('type = "own_file_materialize_test"\n') +@pytest.mark.parametrize("type_, written", [ + ("own_file_materialize_test", "an own_file_materialize_test artifact"), + ("own_environment_materialize_test", "an own_environment_materialize_test artifact"), + ("own_universe_materialize_test", "an own_universe_materialize_test artifact"), +]) +def test_a_plugin_type_with_a_from_toml_of_its_own_is_refused(bundle_dir, type_, written): + (bundle_dir / "artifacts/greeting/artifact.toml").write_text(f'type = "{type_}"\n') - assert _problems(lambda: _run(bundle_dir)) == ( - "artifacts/greeting: writing an own_file_materialize_test artifact isn't supported yet", - ) + assert _problems(lambda: _run(bundle_dir)) == (f"artifacts/greeting: writing {written} isn't supported yet",) def test_an_eval_is_rewritten_only_when_it_changes(bundle_dir): diff --git a/tst/unit/bundle/toml_refs_test.py b/tst/unit/bundle/toml_refs_test.py index 59eaf52a..164ba8d8 100644 --- a/tst/unit/bundle/toml_refs_test.py +++ b/tst/unit/bundle/toml_refs_test.py @@ -5,10 +5,13 @@ import pytest from agent_env.a2a_agent import A2AAgent +from agent_env.artifact import Artifact, EnvironmentArtifact, EnvironmentUniverseArtifact, FileArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.ref import ArtifactRef +from agent_env.artifact.store import ArtifactStore from agent_env.bundle import parse_bundle from agent_env.bundle.authoring import AuthoringContext -from agent_env.bundle.parse import _EVAL_KEYS +from agent_env.bundle.parse import _EVAL_KEYS, CONFIG_FILES from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.website import WebsiteEnv @@ -20,9 +23,15 @@ WebsiteEnv: ("envs/site", {"environment_name": "site"}), MultiEnv: ("envs/suite", {}), A2AAgent: ("agents/solver", {}), + EnvironmentArtifact: ("artifacts/crm-data", {"environment_name": "crm"}), + EnvironmentUniverseArtifact: ("artifacts/world", {}), } +def _type(cls): + return cls.model_fields["type"].default if issubclass(cls, Artifact) else cls.type + + @pytest.mark.parametrize("cls", [*WRITTEN_FROM_TOML, Eval], ids=lambda cls: cls.__name__) def test_each_declared_path_starts_at_a_key_the_type_takes(cls): takes = set(_EVAL_KEYS) if cls is Eval else set(cls.toml_keys) @@ -36,6 +45,10 @@ def _image(id): _STAND_INS = { # what a load returns for each type a from_toml asks for DockerImageArtifact: _image, + FileArtifact: lambda id: FileArtifact(id=id, version=1, description=id, filename="data.json", + content_type="application/json", s3_url=f"s3://bucket/{id}"), + EnvironmentArtifact: lambda id: EnvironmentArtifact(id=id, version=1, environment_name=id, + file_artifact_ref=ArtifactRef(id=f"{id}-file", version=1)), MCPServerEnv: lambda id: MCPServerEnv(id, 1, docker_image_artifact=_image(f"{id}-image"), environment_name=id), WebsiteEnv: lambda id: WebsiteEnv(id, 1, backend_docker_image_artifact=_image(f"{id}-back"), frontend_docker_image_artifact=_image(f"{id}-front"), environment_name=id), @@ -45,28 +58,37 @@ def _image(id): @pytest.mark.parametrize("cls", WRITTEN_FROM_TOML, ids=lambda cls: cls.__name__) def test_from_toml_loads_each_declared_ref_and_writes_no_other(tmp_path, monkeypatch, cls): folder, needed = WRITTEN_FROM_TOML[cls] - root = layout(tmp_path / "b", {f"{folder}/{'agent' if folder.startswith('agents') else 'env'}.toml": - f'type = "{cls.type}"\n'}) + kind = next(kind for kind in CONFIG_FILES if folder.startswith(f"{kind.value}/")) + root = layout(tmp_path / "b", {f"{folder}/{CONFIG_FILES[kind]}": f'type = "{_type(cls)}"\n'}) entry = next(entry for entry in parse_bundle(root).entries if entry.name == folder.split("/")[1]) sentinels = {ref: f"sentinel-{ref.field}" for ref in cls.toml_refs} - data = {"type": cls.type, **needed, + data = {"type": _type(cls), **needed, **{ref.field: [id] if ref.path.endswith("[]") else id for ref, id in sentinels.items()}} - loads, written = [], {} + loads, written = [], [] def load(self, base, kind, ref, expect): loads.append((kind, ref)) return _STAND_INS[expect](ref) def put(**kwargs): - written.update(kwargs) - return cls(version=1, **kwargs) + written.append(cls(version=1, **kwargs)) + return written[-1] + + def put_document(self, artifact): + written.append(artifact.model_copy(update={"version": 1})) + return written[-1] monkeypatch.setattr(AuthoringContext, "_load", load) - monkeypatch.setattr(cls, "put", put) - entity = cls.from_toml(data, AuthoringContext(parse_bundle(root), entry)) + if issubclass(cls, Artifact): # the real put, so its refs are built as they're stored + monkeypatch.setattr(ArtifactStore, "put_document", put_document) + else: + monkeypatch.setattr(cls, "put", put) + cls.from_toml(data, AuthoringContext(parse_bundle(root), entry)) assert sorted(loads) == sorted((ref.kind, id) for ref, id in sentinels.items()) - assert _referenced(entity.to_dict()) <= set(sentinels.values()) + (entity,) = written + document = entity.model_dump(by_alias=True) if isinstance(entity, Artifact) else entity.to_dict() + assert _referenced(document) and _referenced(document) <= set(sentinels.values()) def _referenced(doc): @@ -77,4 +99,3 @@ def _referenced(doc): if isinstance(item, dict) and "id" in item and "version" in item: found.add(item["id"]) return found - From 26730918b21eb90b78716d256f7f774fc7dd997a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 21:21:11 -0700 Subject: [PATCH 2/6] test(bundle): keep the slow env test's data path intact and pin digest file modes The data-bundle test bound a module-level DATA dict over the tst/data path that the gateway multi test reads when it runs, so that test failed with a TypeError. The digest test's fixture now sets each file to 0644, since a built image hashes other permission bits and the umask decided them. Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/integration/cli/run_bundle_built_envs_test.py | 4 ++-- tst/unit/bundle/ledger_digest_test.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index f38397fc..627f4a59 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -343,7 +343,7 @@ def _data_bundle(root): return root -DATA = {"seed": "environment", "by-ref": "environment", "items-file": "file", "world": "environment_universe"} +WRITTEN = ("seed", "by-ref", "items-file", "world") # the artifacts the data bundle writes def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_through_the_gateway(state): @@ -352,7 +352,7 @@ def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_thro first = _run(root) assert first.exit_code == 0, first.output - for name in DATA: + for name in WRITTEN: assert f"artifacts/{name}: v1 (new)" in first.output, first.output for task in ("folder", "ref", "universe"): assert f"tasks/{task}.json v1: passed (check: 1)" in first.output, first.output diff --git a/tst/unit/bundle/ledger_digest_test.py b/tst/unit/bundle/ledger_digest_test.py index 83bed8dd..a55c4ce7 100644 --- a/tst/unit/bundle/ledger_digest_test.py +++ b/tst/unit/bundle/ledger_digest_test.py @@ -47,7 +47,10 @@ @pytest.fixture def bundle_dir(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) - return layout(tmp_path / "triage", LAYOUT) + root = layout(tmp_path / "triage", LAYOUT) + for rel in LAYOUT: # a built image hashes permission bits other than 0644, which the umask would decide + (root / rel).chmod(0o644) + return root def test_every_digest_an_earlier_release_recorded_is_unchanged(bundle_dir): From 6315e6b2b241cba89f22601fa9a4e7c56b568ce6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 21:21:11 -0700 Subject: [PATCH 3/6] fix(bundle): refuse universe folders that can't be written before any write - A universe folder left without its file (empty, or holding only what the OS leaves) is refused rather than dropped, so the environment it names isn't silently missing. - A folder name that opens or closes with a space is refused, and the environment's own id is validated as well as its file's, so neither fails partway through a write. - The ids a universe's environments and metadata files are written under, and the file an environment wraps from its folder, are claimed in the one-writer check, so one clashing with an image the bundle builds is refused before anything is written. - Messages: renaming an environment's folder renames the environment, so the refusal says how to keep the name; an environment folder with no file suggests `file`; a stored name for the file isn't also reported as an empty folder; a file nested under metadata// is told where it goes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/authoring.py | 64 +++++++++++++++++++++------ src/agent_env/bundle/plan.py | 25 ++++++++++- tst/unit/bundle/artifact_toml_test.py | 64 ++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index f6424d91..980f9bfc 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -240,7 +240,10 @@ def entry_file(bundle: Bundle, entry: BundleEntry) -> tuple[str, Path]: def one_file(bundle: Bundle, entry: BundleEntry, remedy: str = _AS_UNIVERSE) -> dict[str, Path]: """``entry_files`` of a folder that must hold one file. Raises BundleError otherwise, suggesting ``remedy``.""" - files = entry_files(bundle, entry) + return _only(bundle, entry, entry_files(bundle, entry), remedy) + + +def _only(bundle: Bundle, entry: BundleEntry, files: dict[str, Path], remedy: str) -> dict[str, Path]: if len(files) != 1: listed = ", ".join(repr(name) for name in files) raise BundleError([f"{relative(bundle.root, entry.path)}: {with_article(f'{entry.type} {entry.kind.store}')} " @@ -248,13 +251,22 @@ def one_file(bundle: Bundle, entry: BundleEntry, remedy: str = _AS_UNIVERSE) -> return files +# The keys that name the file an environment artifact wraps: ``file``, and the stored document's names for it, which +# its toml check refuses with a pointer to ``file``. +_NAMES_ITS_FILE = ("file", "file_artifact_id", "file_artifact_ref") + + def environment_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: """What an environment artifact's write reads from its folder: its one file, or none when its artifact.toml's ``file`` names the artifact to wrap instead. Raises BundleError otherwise.""" - if not (isinstance(entry.config, dict) and "file" in entry.config): - return one_file(bundle, entry, "name the artifact to wrap with file, or give each other file an artifact " - "folder of its own") files = entry_files(bundle, entry, empty_ok=True) + if not (isinstance(entry.config, dict) and any(name in entry.config for name in _NAMES_ITS_FILE)): + if not files: + raise BundleError([f"{relative(bundle.root, entry.path)}: has no file for the environment to wrap " + f"({', '.join(sorted(LEAVINGS))} don't count); put its one file here, or name the " + "artifact to wrap with file"]) + return _only(bundle, entry, files, "name the artifact to wrap with file, or give each other file an artifact " + "folder of its own") if files: listed = ", ".join(repr(name) for name in files) raise BundleError([f"{relative(bundle.root, entry.path)}: its file is the artifact file names, so the folder " @@ -295,7 +307,11 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: ``__metadata__`` for a metadata file. Raises BundleError listing every problem.""" files = entry_files(bundle, entry, empty_ok=True) where = relative(bundle.root, entry.path) - problems, folders = [], {} + problems = [] + # Every folder counts, so one left without its file is refused rather than dropped. + folders: dict[tuple[bool, str], list[str]] = {(False, name): [] for name in _folders(entry.path) + if name != UNIVERSE_METADATA} + folders.update({(True, name): [] for name in _folders(entry.path / UNIVERSE_METADATA)}) for key in files: parts = key.split("/") metadata = parts[0] == UNIVERSE_METADATA @@ -304,23 +320,33 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: elif len(parts) == 1: problems.append(f"{where}/{key}: a universe folder holds only artifact.toml and a folder for each " "environment; move it into /, or give it an artifact folder of its own") - elif metadata: + elif metadata and len(parts) == 2: problems.append(f"{where}/{key}: {UNIVERSE_METADATA}/ holds a folder for each key, with that key's one " f"file in it ({UNIVERSE_METADATA}//); an environment named " f"{UNIVERSE_METADATA} goes in an artifact folder of its own, named in " "environment_artifacts") else: - problems.append(f"{where}/{key}: an environment's folder holds its one file directly, " - f"{parts[0]}/, with no folders inside") + folder = "/".join(parts[:2]) if metadata else parts[0] + what = "a metadata key's" if metadata else "an environment's" + problems.append(f"{where}/{key}: {what} folder holds its one file directly, {folder}/, with no " + "folders inside") environments, metadata_files = [], [] for (metadata, name), keys in sorted(folders.items()): folder = f"{where}/{UNIVERSE_METADATA}/{name}" if metadata else f"{where}/{name}" if len(keys) != 1: - listed = ", ".join(repr(key.rsplit("/", 1)[-1]) for key in keys) - problems.append(f"{folder}: holds one file, and has {len(keys)} ({listed})") + listed = f" ({', '.join(repr(key.rsplit('/', 1)[-1]) for key in keys)})" if keys else "" + problems.append(f"{folder}: holds one file, and has {len(keys)}{listed}") + continue + # Renaming an environment's folder renames the environment, so say how to keep its name. + rename = "rename the folder" if metadata else ( + f"rename the folder, which renames the environment, or keep the name by giving the environment an " + f'artifact folder of its own, with environment_name = "{name}", named in environment_artifacts') + if name != name.strip(): + problems.append(f"{folder}: a name that opens or closes with a space can't name the artifacts written " + "from it; rename the folder") continue if "__" in name: - problems.append(f"{folder}: a name holding __ could clash with the ids derived from it; rename the folder") + problems.append(f"{folder}: a name holding __ could clash with the ids derived from it; {rename}") continue environment_id = None if metadata else derive_id(entry.id, name) if metadata: @@ -328,9 +354,11 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: else: file_id = derive_id(environment_id, "file") try: - validate_local_id(file_id) + for id in (environment_id, file_id): + if id is not None: + validate_local_id(id) except ValueError as e: - problems.append(f"{folder}: can't name the artifacts written from it ({e}); rename the folder") + problems.append(f"{folder}: can't name the artifacts written from it ({e}); {rename}") continue laid = LaidOut(name, keys[0].rsplit("/", 1)[-1], files[keys[0]], file_id, environment_id) (metadata_files if metadata else environments).append(laid) @@ -339,6 +367,16 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: return UniverseLayout(tuple(environments), tuple(metadata_files), files) +def _folders(path: Path) -> list[str]: + """The names of the folders directly in ``path``, as the walk spells them, or none when it isn't one.""" + try: + with os.scandir(path) as listing: + return [unicodedata.normalize("NFC", child.name) for child in listing + if child.name not in LEAVINGS and child.is_dir()] + except OSError: + return [] # the walk reports why it can't be listed + + def universe_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: """What an environment_universe's write reads from its folder: every file ``universe_layout`` lays out.""" return universe_layout(bundle, entry).files diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index df2235dd..a11beab0 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -41,13 +41,14 @@ from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError -from agent_env.store.ids import LOCAL_PREFIX +from agent_env.store.ids import LOCAL_PREFIX, derive_id from agent_env.store.routing import namespace_routing_enabled from agent_env.task import Task from agent_env.task_step.registry import get_task_step_registry from ._fs import fold, relative, with_article from .authoring import ( + UNIVERSE_METADATA, AuthoringContext, entry_files, environment_files, @@ -171,6 +172,8 @@ def _check_writers(self) -> None: claims.append((key, self._path(source.entry), f"the image built for {self._path(source.entry)}")) else: claims.append((key, self._path(source.entry), self._path(source.entry))) + for id, folder in self._written_beside(source): + claims.append(((BundleKind.ARTIFACT.store, id), folder, f"the artifact written from {folder}")) for entry in self.resolved.entries: for output in entry.outputs: claims.append(((output.kind.value, output.id), f"{self._path(entry.entry)}: step {output.step_id!r}", @@ -181,6 +184,26 @@ def _check_writers(self) -> None: else: writers[key] = writer + def _written_beside(self, source: ResolvedEntry) -> list[tuple[str, str]]: + """The ids an environment or universe artifact writes besides its own, each with the folder it's written + from: the file an environment wraps from its folder, and each of a universe's environments and metadata + files. A folder that can't be written names none; the file check reports why.""" + if source.entry.kind is not BundleKind.ARTIFACT: + return [] + walk = folder_walk(get_artifact_registry().get(canonical_type(source.entry.type))) + bundle, where = self.resolved.bundle, self._path(source.entry) + try: + if walk is environment_files: + return [(derive_id(source.entry.id, "file"), where)] if environment_files(bundle, source.entry) else [] + if walk is universe_files: + layout = universe_layout(bundle, source.entry) + return [*((id, f"{where}/{laid.name}") for laid in layout.environments + for id in (laid.environment_id, laid.file_id)), + *((laid.file_id, f"{where}/{UNIVERSE_METADATA}/{laid.name}") for laid in layout.metadata)] + except BundleError: + pass + return [] + def _check_loops(self) -> None: for component in _components(self.nodes, self.edges, self.rank): start = min(component, key=self.rank.__getitem__) diff --git a/tst/unit/bundle/artifact_toml_test.py b/tst/unit/bundle/artifact_toml_test.py index c6388787..5dbfb080 100644 --- a/tst/unit/bundle/artifact_toml_test.py +++ b/tst/unit/bundle/artifact_toml_test.py @@ -275,6 +275,9 @@ def _refused(files): (_environment("x", "crm") | {"artifacts/x/a.json": "{}", "artifacts/x/b.json": "{}"}, "artifacts/x: an environment artifact holds one file, and this folder has 2 ('a.json', 'b.json'); name the " "artifact to wrap with file, or give each other file an artifact folder of its own"), + (_environment("x", "crm"), + "artifacts/x: has no file for the environment to wrap (.DS_Store, Thumbs.db, __pycache__, desktop.ini don't " + "count); put its one file here, or name the artifact to wrap with file"), ({f"artifacts/x/{TOML}": 'type = "environment"\n', "artifacts/x/a.json": "{}"}, "artifacts/x: artifact.toml: environment_name is required: the environment the file seeds"), (_environment("x", "") | {"artifacts/x/a.json": "{}"}, @@ -303,11 +306,24 @@ def _refused(files): "artifacts/x/gmail/inner/a.json: an environment's folder holds its one file directly, gmail/, with no " "folders inside"), (_universe("x") | {"artifacts/x/g__mail/a.json": "{}"}, - "artifacts/x/g__mail: a name holding __ could clash with the ids derived from it; rename the folder"), + "artifacts/x/g__mail: a name holding __ could clash with the ids derived from it; rename the folder, which " + "renames the environment, or keep the name by giving the environment an artifact folder of its own, with " + 'environment_name = "g__mail", named in environment_artifacts'), + (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/metadata/m__k/m.json": "{}"}, + "artifacts/x/metadata/m__k: a name holding __ could clash with the ids derived from it; rename the folder"), + (_universe("x") | {"artifacts/x/slack /a.json": "{}"}, + "artifacts/x/slack : a name that opens or closes with a space can't name the artifacts written from it; rename " + "the folder"), + (_universe("x") | {"artifacts/x/ slack/a.json": "{}"}, + "artifacts/x/ slack: a name that opens or closes with a space can't name the artifacts written from it; rename " + "the folder"), (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/metadata/m.json": "{}"}, "artifacts/x/metadata/m.json: metadata/ holds a folder for each key, with that key's one file in it " "(metadata//); an environment named metadata goes in an artifact folder of its own, named in " "environment_artifacts"), + (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/metadata/k/sub/m.json": "{}"}, + "artifacts/x/metadata/k/sub/m.json: a metadata key's folder holds its one file directly, metadata/k/, " + "with no folders inside"), (_universe("x", environment_artifacts='["gmail-data"]') | {"artifacts/x/gmail/a.json": "{}"} | _environment("gmail-data", "gmail") | {"artifacts/gmail-data/a.json": "{}"}, "artifacts/x: environment_artifacts[0]: environment_name 'gmail' is also the gmail/ folder's; a universe's " @@ -315,11 +331,12 @@ def _refused(files): (_universe("x", service_artifact_refs='["a"]', metadata='{ m = "f" }') | {"artifacts/x/gmail/a.json": "{}"}, "artifacts/x: artifact.toml: service_artifact_refs is what the stored document calls it; write " "environment_artifacts instead"), -], ids=["environment-two-files", "environment-no-name", "environment-empty-name", "environment-service-name", - "environment-file-artifact-id", "environment-file-and-description", "environment-file-and-a-file", - "environment-over-a-universe", "universe-empty", "universe-root-file", "universe-two-files", - "universe-nested-folder", "universe-double-underscore", "universe-flat-metadata", "universe-repeated-name", - "universe-stored-names"]) +], ids=["environment-two-files", "environment-no-file", "environment-no-name", "environment-empty-name", + "environment-service-name", "environment-file-artifact-id", "environment-file-and-description", + "environment-file-and-a-file", "environment-over-a-universe", "universe-empty", "universe-root-file", + "universe-two-files", "universe-nested-folder", "universe-double-underscore", + "universe-metadata-double-underscore", "universe-trailing-space", "universe-leading-space", + "universe-flat-metadata", "universe-nested-metadata", "universe-repeated-name", "universe-stored-names"]) def test_an_artifact_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): layout(bundle_dir, _refused(files)) @@ -327,6 +344,41 @@ def test_an_artifact_toml_that_cant_be_written_is_refused_before_any_write(bundl assert not local_store().path.exists() +def test_an_environment_naming_its_file_by_its_stored_name_is_told_only_to_rename_the_key(bundle_dir): + layout(bundle_dir, _refused(_environment("x", "crm", file_artifact_id='"f"'))) + + assert _problems(bundle_dir) == ("artifacts/x: artifact.toml: file_artifact_id is what the stored document calls " + "it; write file instead",) + + +def test_a_universe_folder_left_without_its_file_is_refused_rather_than_dropped(bundle_dir): + layout(bundle_dir, _refused(_universe("x") | {"artifacts/x/gmail/a.json": "{}", + "artifacts/x/drive/.DS_Store": ""})) + for empty in ("slack", "metadata/manifest", "gmail-old/inner"): + (bundle_dir / "artifacts/x" / empty).mkdir(parents=True) + + assert sorted(_problems(bundle_dir)) == [ + "artifacts/x/drive: holds one file, and has 0", + "artifacts/x/gmail-old: holds one file, and has 0", + "artifacts/x/metadata/manifest: holds one file, and has 0", + "artifacts/x/slack: holds one file, and has 0", + ] + assert not local_store().path.exists() + + +def test_a_universe_folder_named_like_an_image_the_bundle_builds_is_refused(bundle_dir): + layout(bundle_dir, {**_refused(_universe("slack") | {"artifacts/slack/env_image/a.json": "{}"}), + "envs/slack/Dockerfile": "FROM scratch\n", "envs/slack/env.toml": 'environment_name = "s"\n', + "tasks/t.json": json.dumps([{"id": "env", "type": "deploy_env", "env_id": "slack"}, + {"id": "load", "type": "load_artifact", "env_id": "slack", + "artifact_id": "slack", "depends_on": ["env"]}])}) + + assert _problems(bundle_dir) == ( + "artifacts/slack/env_image: '@local/~/triage/slack__env_image' is also written by the image built for " + "envs/slack; give each its own id",) + assert not local_store().path.exists() + + def test_a_universe_naming_a_store_environment_with_the_name_of_one_of_its_folders_is_refused(bundle_dir): with namespace_routing(): EnvironmentArtifact.put("mail-data", environment_name="gmail", file_artifact=_store_file("mail-rows", b"{}")) From 0085c6b28acbd4dca4447d9ecd98e46e2d3ca12e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 22:16:54 -0700 Subject: [PATCH 4/6] refactor(artifact): one definition of the ids an environment and a universe write beside them - EnvironmentArtifact.derived_file_id and EnvironmentUniverseArtifact.derived_environment_id / derived_metadata_id are the only places these ids are spelled. The bundle's universe layout, the plan's one-writer check, from_toml and the CLI all use them, so none can drift from the others. - `artifact environment put` and `environment-universe put --metadata` now name the files they write `__file` and `__metadata__` (derive_id), as a bundle does, instead of `-file` and `-metadata-`. Stored ids keep resolving; new writes get new names. - `environment-universe get --output-dir` and the bundle's layout share the metadata folder's name. - The keys that name an environment's file are read from toml_stored_names, not restated. - FileArtifact and EnvironmentArtifact are imported at module top in these modules; nothing forces the imports into the functions. - A universe's metadata/ folder spelled in another case (Metadata/) is refused, as a case near miss of any reserved name in a bundle is, instead of becoming an environment named Metadata. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/environment.py | 15 +++++----- .../artifacts/environment_universe.py | 25 ++++++++++------ src/agent_env/bundle/authoring.py | 30 ++++++++++++------- src/agent_env/bundle/plan.py | 5 ++-- src/agent_env/cli/artifact/environment.py | 2 +- .../cli/artifact/environment_universe.py | 4 +-- tst/unit/bundle/artifact_toml_test.py | 21 ++++++++++++- 7 files changed, 69 insertions(+), 33 deletions(-) diff --git a/src/agent_env/artifact/artifacts/environment.py b/src/agent_env/artifact/artifacts/environment.py index 46375227..838f40e4 100644 --- a/src/agent_env/artifact/artifacts/environment.py +++ b/src/agent_env/artifact/artifacts/environment.py @@ -8,6 +8,7 @@ from pydantic import ConfigDict, Field, model_serializer from agent_env.artifact.artifact import Artifact +from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.ref import ArtifactRef from agent_env.entity_refs import EntityRef from agent_env.store.ids import derive_id @@ -15,7 +16,6 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from agent_env.artifact.artifacts.file import FileArtifact from agent_env.bundle.authoring import AuthoringContext @@ -80,20 +80,23 @@ def put( ) return get_artifact_store().put_document(instance) + @staticmethod + def derived_file_id(id: str) -> str: + """The id an environment's file is written under when it's written with the environment: ``__file``.""" + return derive_id(id, "file") + @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> EnvironmentArtifact: """Write the environment authored as ``data`` (its artifact.toml, with ``file`` resolved to a file artifact's id) under ``ctx.id`` and return it: over the artifact ``file`` names, or over the folder's one file, written as ``__file`` with ``description`` defaulting to its name.""" - from agent_env.artifact.artifacts.file import FileArtifact - fields = cls.accept_toml(data, ctx) if "file" in fields: file = ctx.artifact(fields["file"], FileArtifact) else: filename, path = ctx.file() - file = FileArtifact.put_attempt(derive_id(ctx.id, "file"), description=fields.get("description", filename), - file_path=str(path), filename=filename) + file = FileArtifact.put_attempt(cls.derived_file_id(ctx.id), file_path=str(path), filename=filename, + description=fields.get("description", filename)) return cls.put(ctx.id, environment_name=fields["environment_name"], file_artifact=file) @classmethod @@ -114,8 +117,6 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: return fields def get_file_artifact(self) -> FileArtifact: - from agent_env.artifact.artifacts.file import FileArtifact - if self.file_artifact_ref is not None: return FileArtifact.get( self.file_artifact_ref.id, version=self.file_artifact_ref.version diff --git a/src/agent_env/artifact/artifacts/environment_universe.py b/src/agent_env/artifact/artifacts/environment_universe.py index 2f67fff7..d427888a 100644 --- a/src/agent_env/artifact/artifacts/environment_universe.py +++ b/src/agent_env/artifact/artifacts/environment_universe.py @@ -8,15 +8,16 @@ from pydantic import AliasChoices, ConfigDict, Field, model_serializer +from agent_env.artifact.artifacts.environment import EnvironmentArtifact +from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.ref import ArtifactRef from agent_env.artifact.universe import Universe from agent_env.entity_refs import EntityRef +from agent_env.store.ids import derive_id logger = logging.getLogger(__name__) if TYPE_CHECKING: - from agent_env.artifact.artifacts.file import FileArtifact - from agent_env.artifact.artifacts.environment import EnvironmentArtifact from agent_env.bundle.authoring import AuthoringContext @@ -34,6 +35,9 @@ class EnvironmentUniverseArtifact(Universe): "service_artifact_refs": "environment_artifacts", "environment_artifact_refs": "environment_artifacts", "service_artifact_ids": "environment_artifacts", "metadata": "a metadata// folder holding its file", "metadata_refs": "a metadata// folder holding its file"} + # What a universe's metadata is called: the folder `environment-universe get --output-dir` writes its files to, and + # the segment of the ids they're written under. + metadata_name: ClassVar[str] = "metadata" type: Literal["environment_universe"] = "environment_universe" # serialization_alias pins the emitted key: the attribute is renamed, the document is not. @@ -109,6 +113,16 @@ def put( ) return get_artifact_store().put_document(instance) + @staticmethod + def derived_environment_id(id: str, environment_name: str) -> str: + """The id an environment is written under when it's written with the universe: ``__``.""" + return derive_id(id, environment_name) + + @classmethod + def derived_metadata_id(cls, id: str, key: str) -> str: + """The id a metadata file is written under when it's written with the universe: ``__metadata__``.""" + return derive_id(derive_id(id, cls.metadata_name), key) + @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> EnvironmentUniverseArtifact: """Write the universe authored as ``data`` (its artifact.toml, with ``environment_artifacts`` resolved to @@ -117,9 +131,6 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> EnvironmentUniverseArti an environment of that name, ``__`` over ``____file``, and each ``metadata//`` folder's file as ``__metadata__``. Its environments are the folders', in name order, then the ones ``environment_artifacts`` names. Nothing is written when it has none, or two of one name.""" - from agent_env.artifact.artifacts.environment import EnvironmentArtifact - from agent_env.artifact.artifacts.file import FileArtifact - fields = cls.accept_toml(data, ctx) layout = ctx.universe() named = [ctx.artifact(ref, EnvironmentArtifact) for ref in fields.get("environment_artifacts", [])] @@ -151,8 +162,6 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: return fields def get_environment_artifacts(self) -> list[EnvironmentArtifact]: - from agent_env.artifact.artifacts.environment import EnvironmentArtifact - if self.environment_artifact_refs is not None: return [ EnvironmentArtifact.get(ref.id, version=ref.version) @@ -192,8 +201,6 @@ def get_file_artifacts(self) -> dict[str, FileArtifact]: return out def get_metadata(self) -> dict[str, FileArtifact]: - from agent_env.artifact.artifacts.file import FileArtifact - if self.metadata_refs: return { k: FileArtifact.get(ref.id, version=ref.version) diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 980f9bfc..5b220790 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -5,22 +5,24 @@ import os import stat import unicodedata +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from collections.abc import Mapping from typing import Any, NoReturn, TypeVar, overload from agent_env.artifact.artifact import Artifact +from agent_env.artifact.artifacts.environment import EnvironmentArtifact +from agent_env.artifact.artifacts.environment_universe import EnvironmentUniverseArtifact from agent_env.entity_refs import EntityKind, parse_toml_ref from agent_env.env.env import Env from agent_env.env.envs._deployment import provider_refusal from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider from agent_env.providers.env_providers.env_provider import _env_provider_class -from agent_env.store.ids import derive_id, validate_local_id +from agent_env.store.ids import validate_local_id from agent_env.utils.card_naming import card_names_in_files -from ._fs import os_reason, relative, show, with_article +from ._fs import fold, os_reason, relative, show, with_article from .parse import CONFIG_FILES, LEAVINGS, NAMED_BY, Bundle, BundleEntry, BundleError A = TypeVar("A", bound=Artifact) @@ -253,7 +255,7 @@ def _only(bundle: Bundle, entry: BundleEntry, files: dict[str, Path], remedy: st # The keys that name the file an environment artifact wraps: ``file``, and the stored document's names for it, which # its toml check refuses with a pointer to ``file``. -_NAMES_ITS_FILE = ("file", "file_artifact_id", "file_artifact_ref") +_NAMES_ITS_FILE = ("file", *(stored for stored, key in EnvironmentArtifact.toml_stored_names.items() if key == "file")) def environment_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: @@ -276,7 +278,7 @@ def environment_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: # The folder of a universe's metadata files, as `environment-universe get --output-dir` writes them. -UNIVERSE_METADATA = "metadata" +UNIVERSE_METADATA = EnvironmentUniverseArtifact.metadata_name @dataclass(frozen=True) @@ -307,13 +309,19 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: ``__metadata__`` for a metadata file. Raises BundleError listing every problem.""" files = entry_files(bundle, entry, empty_ok=True) where = relative(bundle.root, entry.path) - problems = [] + top = _folders(entry.path) + # A metadata/ folder spelled in another case is a typo, as for every reserved name in a bundle. + near = sorted(name for name in top if name != UNIVERSE_METADATA and fold(name) == fold(UNIVERSE_METADATA)) + problems = [f"{where}/{name}: rename to {UNIVERSE_METADATA}; names are case-sensitive, and an environment named " + f"{name} goes in an artifact folder of its own, named in environment_artifacts" for name in near] # Every folder counts, so one left without its file is refused rather than dropped. - folders: dict[tuple[bool, str], list[str]] = {(False, name): [] for name in _folders(entry.path) - if name != UNIVERSE_METADATA} + folders: dict[tuple[bool, str], list[str]] = {(False, name): [] for name in top + if name != UNIVERSE_METADATA and name not in near} folders.update({(True, name): [] for name in _folders(entry.path / UNIVERSE_METADATA)}) for key in files: parts = key.split("/") + if parts[0] in near: + continue metadata = parts[0] == UNIVERSE_METADATA if len(parts) == (3 if metadata else 2): folders.setdefault((metadata, parts[-2]), []).append(key) @@ -348,11 +356,11 @@ def universe_layout(bundle: Bundle, entry: BundleEntry) -> UniverseLayout: if "__" in name: problems.append(f"{folder}: a name holding __ could clash with the ids derived from it; {rename}") continue - environment_id = None if metadata else derive_id(entry.id, name) if metadata: - file_id = derive_id(derive_id(entry.id, UNIVERSE_METADATA), name) + environment_id, file_id = None, EnvironmentUniverseArtifact.derived_metadata_id(entry.id, name) else: - file_id = derive_id(environment_id, "file") + environment_id = EnvironmentUniverseArtifact.derived_environment_id(entry.id, name) + file_id = EnvironmentArtifact.derived_file_id(environment_id) try: for id in (environment_id, file_id): if id is not None: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index a11beab0..58fe0285 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -41,7 +41,7 @@ from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError -from agent_env.store.ids import LOCAL_PREFIX, derive_id +from agent_env.store.ids import LOCAL_PREFIX from agent_env.store.routing import namespace_routing_enabled from agent_env.task import Task from agent_env.task_step.registry import get_task_step_registry @@ -194,7 +194,8 @@ def _written_beside(self, source: ResolvedEntry) -> list[tuple[str, str]]: bundle, where = self.resolved.bundle, self._path(source.entry) try: if walk is environment_files: - return [(derive_id(source.entry.id, "file"), where)] if environment_files(bundle, source.entry) else [] + return ([(EnvironmentArtifact.derived_file_id(source.entry.id), where)] + if environment_files(bundle, source.entry) else []) if walk is universe_files: layout = universe_layout(bundle, source.entry) return [*((id, f"{where}/{laid.name}") for laid in layout.environments diff --git a/src/agent_env/cli/artifact/environment.py b/src/agent_env/cli/artifact/environment.py index 74a00f00..deeeb96f 100644 --- a/src/agent_env/cli/artifact/environment.py +++ b/src/agent_env/cli/artifact/environment.py @@ -23,7 +23,7 @@ def put(artifact_id: str, description: str, environment_name: str | None, filepa click.echo("Creating FileArtifact...") file_artifact = FileArtifact.put( - id=f"{artifact_id}-file", + id=EnvironmentArtifact.derived_file_id(artifact_id), description=description, file_path=str(filepath), ) diff --git a/src/agent_env/cli/artifact/environment_universe.py b/src/agent_env/cli/artifact/environment_universe.py index 881cf6e1..ec6be818 100644 --- a/src/agent_env/cli/artifact/environment_universe.py +++ b/src/agent_env/cli/artifact/environment_universe.py @@ -64,7 +64,7 @@ def environment_universe_put( if not p.is_file(): click.echo(f"Error: metadata file not found: {file_path}", err=True) raise SystemExit(1) - fa_id = f"{artifact_id}-metadata-{key}" + fa_id = EnvironmentUniverseArtifact.derived_metadata_id(artifact_id, key) click.echo(f"Creating metadata FileArtifact: key={key} file={file_path}...") fa = FileArtifact.put( id=fa_id, @@ -137,7 +137,7 @@ def environment_universe_get( click.echo(f"Downloading {len(metadata_artifacts)} metadata file(s)...") for key, file_artifact in metadata_artifacts.items(): data = file_artifact.load() - metadata_dir = out / "metadata" / key + metadata_dir = out / EnvironmentUniverseArtifact.metadata_name / key metadata_dir.mkdir(parents=True, exist_ok=True) dest = metadata_dir / file_artifact.filename dest.write_bytes(data) diff --git a/tst/unit/bundle/artifact_toml_test.py b/tst/unit/bundle/artifact_toml_test.py index 5dbfb080..b0453845 100644 --- a/tst/unit/bundle/artifact_toml_test.py +++ b/tst/unit/bundle/artifact_toml_test.py @@ -241,6 +241,21 @@ def test_a_universe_the_cli_downloads_is_written_from_a_bundle_equal_to_its_orig assert _fields(written.get_file_artifact()) == _fields(cli_written.get_file_artifact()) +def test_the_cli_names_the_files_it_writes_with_an_environment_and_a_universe_as_a_bundle_does(bundle_dir, tmp_path): + (tmp_path / "data.json").write_text("{}\n") + (tmp_path / "manifest.json").write_text("{}\n") + for argv in (["artifact", "environment", "put", str(tmp_path / "data.json"), "--id", "crm-data", "--description", + "crm", "--environment-name", "crm"], + ["artifact", "environment-universe", "put", "--id", "world", "--environment-artifact", "crm-data", + "--metadata", f"manifest={tmp_path / 'manifest.json'}"]): + put = CliRunner().invoke(cli, argv) + assert put.exit_code == 0, put.output + + with namespace_routing(): + assert EnvironmentArtifact.get("crm-data").file_artifact_ref.id == "crm-data__file" + assert EnvironmentUniverseArtifact.get("world").metadata_refs["manifest"].id == "world__metadata__manifest" + + def test_an_environment_written_either_way_equals_the_one_the_cli_writes(bundle_dir, tmp_path): (tmp_path / "data.json").write_text('{"rows": 1}\n') put = CliRunner().invoke(cli, ["artifact", "environment", "put", str(tmp_path / "data.json"), "--id", "original", @@ -321,6 +336,9 @@ def _refused(files): "artifacts/x/metadata/m.json: metadata/ holds a folder for each key, with that key's one file in it " "(metadata//); an environment named metadata goes in an artifact folder of its own, named in " "environment_artifacts"), + (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/Metadata/k/m.json": "{}"}, + "artifacts/x/Metadata: rename to metadata; names are case-sensitive, and an environment named Metadata goes in " + "an artifact folder of its own, named in environment_artifacts"), (_universe("x") | {"artifacts/x/gmail/a.json": "{}", "artifacts/x/metadata/k/sub/m.json": "{}"}, "artifacts/x/metadata/k/sub/m.json: a metadata key's folder holds its one file directly, metadata/k/, " "with no folders inside"), @@ -336,7 +354,8 @@ def _refused(files): "environment-file-and-a-file", "environment-over-a-universe", "universe-empty", "universe-root-file", "universe-two-files", "universe-nested-folder", "universe-double-underscore", "universe-metadata-double-underscore", "universe-trailing-space", "universe-leading-space", - "universe-flat-metadata", "universe-nested-metadata", "universe-repeated-name", "universe-stored-names"]) + "universe-flat-metadata", "universe-metadata-in-another-case", "universe-nested-metadata", + "universe-repeated-name", "universe-stored-names"]) def test_an_artifact_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): layout(bundle_dir, _refused(files)) From a2d99a2c04877938c37d629a6dabff0ba77285c3 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 22:25:26 -0700 Subject: [PATCH 5/6] feat(bundle): refuse a bundle's environment that seeds no server of the env a task loads it into A load_artifact step loading the bundle's own environment or universe into an env is checked before anything is written: each environment it seeds (a universe's folders and what its environment_artifacts names, or an environment's own name) must be the environment_name of one of the env's servers (an MCP server's or website's own, or each of a multi's envs'). One named after no server is a typo or a stray, which a multi would skip without a word and an MCP server or website would fail on mid-run. A store environment or universe isn't checked: a server loading a shared universe takes its own environment from it and skips the rest. A check that reads no store skips a store env's servers. The slow data-bundle test's universe no longer adds an environment for another server, which this now refuses. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/plan.py | 84 +++++++++++++++++ .../cli/run_bundle_built_envs_test.py | 10 +-- tst/unit/bundle/artifact_toml_test.py | 90 ++++++++++++++++++- 3 files changed, 176 insertions(+), 8 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 58fe0285..f082e861 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -45,6 +45,7 @@ from agent_env.store.routing import namespace_routing_enabled from agent_env.task import Task from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from ._fs import fold, relative, with_article from .authoring import ( @@ -143,6 +144,7 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: self._check_env_types(closure) self._check_multi_names(closure, store_latest) self._check_universes(closure, store_latest) + self._check_loaded_environments(closure, store_latest) if self.problems: raise BundleError(self.problems) return Plan(self.resolved, self._ordered(closure), tuple(selected_tasks), tuple(selected_evals), store_refs, @@ -158,6 +160,7 @@ def check(self) -> None: self._build_tasks(everything) self._check_files(everything) self._check_universes(everything, None) + self._check_loaded_environments(everything, None) if self.problems: raise BundleError(self.problems) @@ -453,6 +456,87 @@ def _check_universes(self, closure: set[_Key], store_latest: dict[tuple[EntityKi self.problems.append(f"{where}: a universe needs an environment: a folder of its own, named after it " "and holding its file, or one environment_artifacts names") + def _check_loaded_environments(self, closure: set[_Key], + store_latest: dict[tuple[EntityKind, str], int] | None) -> None: + """Each environment the bundle writes seeds a server of the env a task loads it into: one named after no + server is a typo or a stray, which a multi would skip and an MCP server or website would fail on. A store + environment or universe isn't checked, since a server loading a shared universe takes its own environment + from it. Without ``store_latest`` (a check that reads no store), a store env's servers aren't known.""" + registry = get_task_step_registry() + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if not (isinstance(source, ResolvedEntry) and source.entry.kind is BundleKind.TASK): + continue + loads: dict[str, list[Reference]] = {} # each load_artifact step's references, by the step's label + for step in source.config: + cls = registry.get(step["type"]) if isinstance(step.get("type"), str) else None + if cls is not None and issubclass(cls, LoadArtifactTaskStep): + loads[f"step {step.get('id')!r}"] = [] + for ref in source.references: + label = ref.where.partition(": ")[0] + if label in loads: + loads[label].append(ref) + for label, refs in loads.items(): + env = next((ref for ref in refs if ref.kind is EntityKind.ENV), None) + servers = self._server_names(env, store_latest) if env is not None else None + if servers is None: + continue + into = self._path(env.local) if env.local is not None else f"env {env.id!r}" + for ref in refs: + for what, name in self._seeded(ref, store_latest): + if name not in servers: + listed = ", ".join(repr(server) for server in sorted(servers)) + self.problems.append( + f"{self._path(source.entry)}: {label}: {what} seeds an environment named {name!r}, " + f"and {into} has no server by that name ({listed}); name it after the server it " + "seeds, or leave it out") + + def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> set[str] | None: + """The environment_names of the servers the env ``ref`` names runs: an MCP server's or a website's own, or + each of a multi's envs'. None when they aren't all known.""" + if isinstance(ref.local, BundleEntry): + cls = get_env_registry().get(ref.local.type) + if cls is not None and issubclass(cls, MultiEnv): + children = self.nodes[_key(ref.local)].references + names = {self._environment_name(child, store_latest) for child in children + if child.kind is EntityKind.ENV} + else: + names = {self.accepted.get(_key(ref.local), {}).get("environment_name")} + return None if None in names else names + if ref.local is not None or store_latest is None: + return None + try: + env = Env.get(ref.id, ref.version if ref.version is not None else store_latest.get((ref.kind, ref.id))) + except (NotFoundError, ValueError, KeyError, TypeError): + return None # reported by the store ref check + if isinstance(env, MultiEnv): + return {child.environment_name for child in [*env.mcp_server_envs, *env.website_envs]} + name = getattr(env, "environment_name", None) + return {name} if name else None + + def _seeded(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> list[tuple[str, str]]: + """Each environment the bundle's own environment or universe ``ref`` names seeds, as (where it's named, + its environment_name): a universe's folders and what its environment_artifacts names, or an environment's + own name. Nothing for a store artifact or another type.""" + entry = ref.local + if not isinstance(entry, BundleEntry) or entry.kind is not BundleKind.ARTIFACT: + return [] + cls = get_artifact_registry().get(canonical_type(entry.type)) + walk, where = folder_walk(cls), self._path(entry) + if walk is environment_files: + name = self.accepted.get(_key(entry), {}).get("environment_name") + return [(where, name)] if name else [] + if walk is not universe_files: + return [] + try: + layout = universe_layout(self.resolved.bundle, entry) + except BundleError: + return [] # reported by the file check + named = [(f"{where}: {child.where}", self._environment_name(child, store_latest)) + for child in self.nodes[_key(entry)].references] + return [*((f"{where}/{laid.name}", laid.name) for laid in layout.environments), + *((what, name) for what, name in named if name is not None)] + def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> str | None: """The environment_name of the env or environment artifact ``ref`` names: a bundle one's from its accepted toml, a store one's at the version the plan read, or None when that's unknown.""" diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index 627f4a59..c089a3c3 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -325,16 +325,14 @@ def _load_task(root, name, artifact, items): def _data_bundle(root): """The items server, and data for it in each shape a bundle writes: an environment over its folder's file (seed), one naming a file artifact (by-ref over items-file), and a universe laid out as `environment-universe get - --output-dir` writes one, with the items server's data in its items/ folder, a metadata file, and an environment - for another server (mail-data), which the items server skips.""" + --output-dir` writes one, with the items server's data in its items/ folder and a metadata file.""" _items(root / "envs/items", "v1") _write(root / "artifacts", { "seed/artifact.toml": 'type = "environment"\nenvironment_name = "items"\n', "seed/items.json": _seed(["folder"]), "items-file/items.json": _seed(["by-ref"]), "by-ref/artifact.toml": 'type = "environment"\nenvironment_name = "items"\nfile = "items-file"\n', - "mail-data/artifact.toml": 'type = "environment"\nenvironment_name = "mail"\n', "mail-data/mail.json": "{}\n", - "world/artifact.toml": 'type = "environment_universe"\nenvironment_artifacts = ["mail-data"]\n', + "world/artifact.toml": 'type = "environment_universe"\n', "world/items/items.json": _seed(["universe"]), "world/metadata/manifest/manifest.json": '{"m": 1}\n', }) _load_task(root, "folder", "seed", ["folder"]) @@ -359,7 +357,7 @@ def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_thro with namespace_routing(): ids = {entry.name: entry.id for entry in parse_bundle(root).entries} world = EnvironmentUniverseArtifact.get(ids["world"]) - assert [env.environment_name for env in world.get_environment_artifacts()] == ["items", "mail"] + assert [env.environment_name for env in world.get_environment_artifacts()] == ["items"] assert list(world.get_metadata()) == ["manifest"] (root / "artifacts/seed/items.json").write_text(_seed(["folder", "edited"])) @@ -371,7 +369,7 @@ def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_thro assert edited.exit_code == 0, edited.output assert "artifacts/seed: v2 (files changed: items.json)" in edited.output, edited.output assert "artifacts/world: v2 (files changed: items/items.json)" in edited.output, edited.output - assert "artifacts/mail-data: v1, unchanged" in edited.output and "envs/items: v1, unchanged" in edited.output + assert "envs/items: v1, unchanged" in edited.output, edited.output for task in ("folder", "universe"): assert f"tasks/{task}.json v1: passed (check: 1)" in edited.output, edited.output diff --git a/tst/unit/bundle/artifact_toml_test.py b/tst/unit/bundle/artifact_toml_test.py index b0453845..0dfa92f1 100644 --- a/tst/unit/bundle/artifact_toml_test.py +++ b/tst/unit/bundle/artifact_toml_test.py @@ -1,7 +1,8 @@ """Environment and environment_universe artifacts written from a bundle's artifact folders: an environment over the file its folder holds or the file artifact it names, and a universe laid out as ``environment-universe get --output-dir`` writes one, adding the environments it names. Each is checked before any write, rewritten when -what it records changes, and equal, field for field, to one the CLI writes from the same files.""" +what it records changes, and equal, field for field, to one the CLI writes from the same files. Each environment +the bundle writes must seed a server of the env a task loads it into.""" import json @@ -9,6 +10,7 @@ from click.testing import CliRunner from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact, FileArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.registry import canonical_type from agent_env.artifact.store import get_artifact_store from agent_env.bundle import BundleError, parse_bundle @@ -16,6 +18,8 @@ from agent_env.bundle.plan import check_bundle from agent_env.bundle.resolve import resolve_bundle from agent_env.cli import cli +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv from agent_env.store.routing import namespace_routing from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of @@ -387,7 +391,8 @@ def test_a_universe_folder_left_without_its_file_is_refused_rather_than_dropped( def test_a_universe_folder_named_like_an_image_the_bundle_builds_is_refused(bundle_dir): layout(bundle_dir, {**_refused(_universe("slack") | {"artifacts/slack/env_image/a.json": "{}"}), - "envs/slack/Dockerfile": "FROM scratch\n", "envs/slack/env.toml": 'environment_name = "s"\n', + "envs/slack/Dockerfile": "FROM scratch\n", + "envs/slack/env.toml": 'environment_name = "env_image"\n', "tasks/t.json": json.dumps([{"id": "env", "type": "deploy_env", "env_id": "slack"}, {"id": "load", "type": "load_artifact", "env_id": "slack", "artifact_id": "slack", "depends_on": ["env"]}])}) @@ -398,6 +403,87 @@ def test_a_universe_folder_named_like_an_image_the_bundle_builds_is_refused(bund assert not local_store().path.exists() +# Loaded into an env + + +def _server(name): + return {f"envs/{name}/Dockerfile": "FROM scratch\nCOPY . /app\n", + f"envs/{name}/env.toml": f'environment_name = "{name}"\n'} + + +SUITE = {**_server("crm"), **_server("slack"), + "envs/suite/env.toml": 'type = "multi"\nmcp_server_envs = ["crm", "slack"]\n'} + + +def _loading(files, artifact, env): + return {**files, "tasks/t.json": json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": env}, + {"id": "load", "type": "load_artifact", "env_id": env, "artifact_id": artifact, "depends_on": ["env"]}, + ])} + + +def _store_server(name): + image = get_artifact_store().put_document(DockerImageArtifact( + id=f"{name}-image", description=name, image_name=f"registry.example/{name}:v1", + tar_gz_s3_url=f"s3://bucket/{name}.tar.gz")) + return MCPServerEnv.put(id=name, docker_image_artifact=image, environment_name=name) + + +def test_a_universe_folder_named_after_no_server_of_the_env_it_loads_into_is_refused(bundle_dir, monkeypatch): + layout(bundle_dir, _loading({**SUITE, **_universe("world"), "artifacts/world/crm/a.json": "{}", + "artifacts/world/slakc/a.json": "{}"}, "world", "suite")) + problem = ("tasks/t.json: step 'load': artifacts/world/slakc seeds an environment named 'slakc', and envs/suite " + "has no server by that name ('crm', 'slack'); name it after the server it seeds, or leave it out") + + assert _problems(bundle_dir) == (problem,) + monkeypatch.setattr("agent_env.config.runtime.Config.get_document_store", lambda self: RefusingStore()) + with pytest.raises(BundleError) as caught: + check_bundle(resolve_bundle(parse_bundle(bundle_dir))) + assert caught.value.problems == (problem,) + + +def test_an_environment_named_after_no_server_of_the_env_it_loads_into_is_refused(bundle_dir): + layout(bundle_dir, _loading({**_server("crm"), **_environment("crm-data", "crn"), + "artifacts/crm-data/a.json": "{}"}, "crm-data", "crm")) + + assert _problems(bundle_dir) == ( + "tasks/t.json: step 'load': artifacts/crm-data seeds an environment named 'crn', and envs/crm has no server " + "by that name ('crm'); name it after the server it seeds, or leave it out",) + + +def test_a_store_environment_a_universe_names_must_seed_a_server_too(bundle_dir): + with namespace_routing(): + EnvironmentArtifact.put("mail-data", environment_name="gmail", file_artifact=_store_file("mail-rows", b"{}")) + layout(bundle_dir, _loading({**SUITE, **_universe("world", environment_artifacts='["mail-data"]'), + "artifacts/world/crm/a.json": "{}"}, "world", "suite")) + + assert _problems(bundle_dir) == ( + "tasks/t.json: step 'load': artifacts/world: environment_artifacts[0] seeds an environment named 'gmail', and " + "envs/suite has no server by that name ('crm', 'slack'); name it after the server it seeds, or leave it out",) + + +def test_a_universe_loaded_into_a_store_multi_is_checked_against_its_servers(bundle_dir): + with namespace_routing(): + MultiEnv.put(id="both", mcp_server_envs=[_store_server("crm"), _store_server("slack")]) + layout(bundle_dir, _loading({**_universe("world"), "artifacts/world/crm/a.json": "{}", + "artifacts/world/drive/a.json": "{}"}, "world", "both")) + + assert _problems(bundle_dir) == ( + "tasks/t.json: step 'load': artifacts/world/drive seeds an environment named 'drive', and env 'both' has no " + "server by that name ('crm', 'slack'); name it after the server it seeds, or leave it out",) + + +def test_a_store_universe_loaded_into_one_server_gives_it_its_own_environment(bundle_dir): + with namespace_routing(): + environments = [EnvironmentArtifact.put(f"{name}-data", environment_name=name, + file_artifact=_store_file(f"{name}-rows", b"{}")) + for name in ("crm", "gmail", "slack")] + EnvironmentUniverseArtifact.put("shared", environment_artifacts=environments) + layout(bundle_dir, _loading(_server("crm"), "shared", "crm")) + + assert plan_of(bundle_dir).writes + + def test_a_universe_naming_a_store_environment_with_the_name_of_one_of_its_folders_is_refused(bundle_dir): with namespace_routing(): EnvironmentArtifact.put("mail-data", environment_name="gmail", file_artifact=_store_file("mail-rows", b"{}")) From 0d3ad8cdffccb9fb2545227c7f73cfc36d5baeb5 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 23:02:05 -0700 Subject: [PATCH 6/6] test(bundle): a universe composed every way seeds each server of a multi of bundle and store servers A slow-tier test writes, deploys and reads back the combinations the data-bundle test left out: - a universe whose environments come from its folder, a bundle environment over a store file pinned by version, and a store environment, loaded into a multi of two bundle servers and one the CLI put in the store; each server holds exactly the data meant for it; - a store universe shared across servers, loaded into one bundle server, which takes its own environment and skips the rest; - a dry run afterwards reuses everything. The check reads each server's state behind the gateway rather than over MCP: every items server registers list_items under the same name, which the gateway serves from only one of them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_built_envs_test.py | 112 +++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index c089a3c3..01eb400c 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -4,7 +4,9 @@ deploys and answers a verifier calling it: an MCP server built from its folder, one built from a Dockerfile elsewhere under a name of its own, one over a store image, a website, and a multi of bundle and store envs, which is rewritten when a child is. Environments and a universe the bundle writes load into an MCP server deployed through the gateway, -and an edit to their files loads anew. Needs a Docker daemon and the local registry.""" +and an edit to their files loads anew. A universe composed every way a bundle composes one seeds each server of a multi +of bundle and store servers, and a store universe shared across servers seeds the one server it's loaded into. Needs a +Docker daemon and the local registry.""" import hashlib import json @@ -15,7 +17,7 @@ import pytest from click.testing import CliRunner -from agent_env.artifact import EnvironmentUniverseArtifact +from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact, FileArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.bundle import parse_bundle from agent_env.cli import cli @@ -374,6 +376,112 @@ def test_environments_and_a_universe_written_from_a_bundle_load_into_an_env_thro assert f"tasks/{task}.json v1: passed (check: 1)" in edited.output, edited.output +# Checks each items server CONFIG names holds exactly the items loaded into it, read from its export-state route behind +# the multi's gateway. Not over MCP: every items server registers list_items under that one name, which the gateway +# serves from only one server. CONFIG goes above it. +VERIFY_SEEDED = ''' +import httpx + + +async def verify(mcp_url): + gateway = mcp_url.rsplit("/mcp", 1)[0] + results = [] + async with httpx.AsyncClient(timeout=30) as client: + for server, items in CONFIG["servers"].items(): + response = await client.get(f"{gateway}/svc/mcp-{server}/export-state") + listed = response.json() if response.status_code == 200 else {} + description = f"{server} holds what it was seeded with, {items}: {response.status_code} {listed}" + results.append({"id": server, "result": listed.get("items") == items, "description": description}) + return results +''' + + +def _put_store_data(state): + """What a user puts in the store before a bundle names it: the items server as the env ledger, built by the CLI; + ledger's environment; stock's file; and a universe shared across servers, with environments for items and gmail.""" + source = state / "ledger-src" + _items(source, "v1") + for argv in ( + ["env", "mcp-server", "put", "--id", "ledger", "--environment-name", "ledger", "--dockerfile", + str(source / "Dockerfile"), "--context", str(source), "--platform", ""], + *(["artifact", "environment", "put", str(_data_file(state, name, items)), "--id", id, "--description", name, + "--environment-name", name] + for id, name, items in (("ledger-data", "ledger", ["from-a-store-environment"]), + ("shared-items", "items", ["from-a-shared-universe"]), + ("shared-gmail", "gmail", ["for-another-server"]))), + ["artifact", "environment-universe", "put", "--id", "shared", "--environment-artifact", "shared-items", + "--environment-artifact", "shared-gmail"], + ): + put = CliRunner().invoke(cli, argv) + assert put.exit_code == 0, put.output + with namespace_routing(): + FileArtifact.put_bytes("stock-rows", description="stock", filename="stock.json", + content=_seed(["from-a-store-file"]).encode(), content_type="application/json") + + +def _data_file(state, name, items): + path = state / "store-data" / f"{name}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_seed(items)) + return path + + +def _composed_bundle(root): + """A multi of two bundle servers, items and stock, and the store server ledger, and a universe composed every way: + items' environment from its folder, stock's a bundle environment over a pinned store file, and ledger's a store + environment. Task universe loads it into the multi; task shared loads the store's shared universe into items.""" + envs = root / "envs" + _items(envs / "items", "v1") + _items(envs / "stock", "v1") + (envs / "stock/env.toml").write_text('environment_name = "stock"\n') + (envs / "suite").mkdir() + (envs / "suite/env.toml").write_text('type = "multi"\nmcp_server_envs = ["items", "stock", "ledger"]\n') + _write(root / "artifacts", { + "stock-data/artifact.toml": 'type = "environment"\nenvironment_name = "stock"\n' + 'file = { artifact = "stock-rows", version = 1 }\n', + "world/artifact.toml": 'type = "environment_universe"\nenvironment_artifacts = ["stock-data", "ledger-data"]\n', + "world/items/items.json": _seed(["from-a-universe-folder"]), + "check-universe/verify.py": f"CONFIG = {SEEDED!r}\n{VERIFY_SEEDED}", + }) + (root / "tasks").mkdir(exist_ok=True) + (root / "tasks/universe.json").write_text(json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": "suite", "sandbox_type": "local"}, + {"id": "load", "type": "load_artifact", "env_id": "suite", "artifact_id": "world", "depends_on": ["env"]}, + {"id": "check", "type": "env_outcome_verifier", "env_id": "suite", "file_artifact_id": "check-universe", + "verifier_id": "universe", "depends_on": ["load"]}, + ])) + _load_task(root, "shared", "shared", ["from-a-shared-universe"]) + return root + + +SEEDED = {"servers": {"items": ["from-a-universe-folder"], "stock": ["from-a-store-file"], + "ledger": ["from-a-store-environment"]}} + + +def test_a_universe_composed_every_way_seeds_each_server_of_a_multi_and_a_shared_one_seeds_its_server(state): + _put_store_data(state) + root = _composed_bundle(state / "composed-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + for task in ("universe", "shared"): + assert f"tasks/{task}.json v1: passed (check: 1)" in first.output, first.output + with namespace_routing(): + ids = {entry.name: entry.id for entry in parse_bundle(root).entries} + environments = EnvironmentUniverseArtifact.get(ids["world"]).get_environment_artifacts() + stock = EnvironmentArtifact.get(ids["stock-data"]) + assert [(env.id, env.environment_name) for env in environments] == [ + (f"{ids['world']}__items", "items"), (ids["stock-data"], "stock"), ("ledger-data", "ledger")] + assert (stock.file_artifact_ref.id, stock.file_artifact_ref.version) == ("stock-rows", 1) + + planned = _run(root, "--dry-run") + + assert planned.exit_code == 0, planned.output + for written in ("artifacts/world", "artifacts/stock-data", "envs/suite", "envs/items", "envs/stock"): + assert f"{written}: v1, unchanged" in planned.output, planned.output + + def _files(root): """A digest of every file under ``root`` but the locks a run takes.""" digest = hashlib.sha256()