diff --git a/src/agent_env/artifact/artifacts/environment.py b/src/agent_env/artifact/artifacts/environment.py index 894e7bf4..838f40e4 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.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 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,9 +80,43 @@ def put( ) return get_artifact_store().put_document(instance) - def get_file_artifact(self) -> FileArtifact: - from agent_env.artifact.artifacts.file import FileArtifact + @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.""" + 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(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 + 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: 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 813eac6e..d427888a 100644 --- a/src/agent_env/artifact/artifacts/environment_universe.py +++ b/src/agent_env/artifact/artifacts/environment_universe.py @@ -3,18 +3,22 @@ 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.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 class EnvironmentUniverseArtifact(Universe): @@ -22,6 +26,19 @@ 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"} + # 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. environment_artifact_refs: Optional[list[ArtifactRef]] = Field( @@ -96,9 +113,55 @@ def put( ) return get_artifact_store().put_document(instance) - def get_environment_artifacts(self) -> list[EnvironmentArtifact]: - from agent_env.artifact.artifacts.environment import EnvironmentArtifact + @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 + 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.""" + 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]: if self.environment_artifact_refs is not None: return [ EnvironmentArtifact.get(ref.id, version=ref.version) @@ -138,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/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..5b220790 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -5,20 +5,24 @@ import os import stat import unicodedata +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path 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 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) @@ -61,6 +65,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 +213,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 +233,170 @@ 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.""" - files = entry_files(bundle, entry) + 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``.""" + 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)}: 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 + + +# 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", *(stored for stored, key in EnvironmentArtifact.toml_stored_names.items() if key == "file")) + + +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.""" + 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 " + 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 = EnvironmentUniverseArtifact.metadata_name + + +@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) + 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 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) + 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 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: + 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 = 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}") + continue + if metadata: + environment_id, file_id = None, EnvironmentUniverseArtifact.derived_metadata_id(entry.id, name) + else: + 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: + validate_local_id(id) + except ValueError as e: + 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) + if problems: + raise BundleError(problems) + 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 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 +408,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..f082e861 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 @@ -39,9 +45,18 @@ 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 AuthoringContext, entry_file, entry_files +from .authoring import ( + UNIVERSE_METADATA, + 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 +65,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 +127,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 +143,8 @@ 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) + 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, @@ -141,6 +159,8 @@ def check(self) -> None: self._check_store_refs(everything, stores=False) 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) @@ -155,6 +175,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}", @@ -165,6 +187,27 @@ 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 ([(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 + 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__) @@ -312,8 +355,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 +425,131 @@ 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 _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.""" 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/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/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index d11c10d8..01eb400c 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -3,7 +3,10 @@ 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. 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 @@ -14,6 +17,7 @@ import pytest from click.testing import CliRunner +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 @@ -277,6 +281,207 @@ 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 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', + "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"]) + _load_task(root, "ref", "by-ref", ["by-ref"]) + _load_task(root, "universe", "world", ["universe"]) + return root + + +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): + root = _data_bundle(state / "data-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + 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 + 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"] + 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 "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 + + +# 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() diff --git a/tst/unit/bundle/artifact_toml_test.py b/tst/unit/bundle/artifact_toml_test.py new file mode 100644 index 00000000..0dfa92f1 --- /dev/null +++ b/tst/unit/bundle/artifact_toml_test.py @@ -0,0 +1,522 @@ +"""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. Each environment +the bundle writes must seed a server of the env a task loads it into.""" + +import json + +import pytest +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 +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.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 + +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_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", + "--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"), + (_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": "{}"}, + "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, 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/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"), + (_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-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-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)) + + assert problem in _problems(bundle_dir) + 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 = "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"]}])}) + + 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() + + +# 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"{}")) + _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..a55c4ce7 --- /dev/null +++ b/tst/unit/bundle/ledger_digest_test.py @@ -0,0 +1,61 @@ +"""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)) + 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): + 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 -