From bd52d565e17fcf321eb3f47e81127abf8ae11cd1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 10:28:48 -0700 Subject: [PATCH] feat(bundle): record what an import wrote, so registering the folder back can tell an edit provenance.json, schema 2, now also holds: - the task map, each task file with the store's task id and version, and the eval's; - a fingerprint of the configured document store's section, which tells one store from another without naming where either is; - each file the import wrote, as its content hash (+x when executable). The pinned store references move from "store" to "pinned". Nothing reads the file yet. The file hash the ledger computes moves to _fs, so both use one. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/_fs.py | 13 +++++-- src/agent_env/bundle/_import_report.py | 42 ++++++++++++++++++++--- src/agent_env/bundle/importer.py | 2 +- src/agent_env/bundle/ledger.py | 11 ++---- tst/unit/bundle/importer_entities_test.py | 33 +++++++++++++++++- tst/unit/bundle/importer_test.py | 6 ++++ 6 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/agent_env/bundle/_fs.py b/src/agent_env/bundle/_fs.py index 37d3e890b..738fbe8b6 100644 --- a/src/agent_env/bundle/_fs.py +++ b/src/agent_env/bundle/_fs.py @@ -1,9 +1,10 @@ """Filesystem plumbing for the bundle parser: a path spelled as the filesystem stores it, the user's -home, a regular file read without surprises, names compared without case, and paths rendered for -messages.""" +home, a regular file read without surprises, a file's content hash, names compared without case, and paths rendered +for messages.""" from __future__ import annotations +import hashlib import os import stat import sys @@ -96,6 +97,14 @@ def read_regular(path: Path) -> bytes: return data +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + digest.update(chunk) + return "sha256:" + digest.hexdigest() + + def fold(name: str) -> str: """The canonical caseless form: casefold output isn't always NFC, so normalize both sides.""" return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold()) diff --git a/src/agent_env/bundle/_import_report.py b/src/agent_env/bundle/_import_report.py index 108c2a79b..2d6fe263c 100644 --- a/src/agent_env/bundle/_import_report.py +++ b/src/agent_env/bundle/_import_report.py @@ -3,13 +3,17 @@ from __future__ import annotations +import hashlib import json import posixpath +import unicodedata from dataclasses import dataclass from datetime import datetime, timezone from importlib.metadata import version as package_version from pathlib import Path +from agent_env.bundle._fs import file_sha256 +from agent_env.config import get_config from agent_env.config.model import ENV_API_KEY, ENV_BASE_URL from agent_env.entity.refs import EntityKind from agent_env.store.image_store.oci_registry_credentials import registry_host_from_ref @@ -17,8 +21,10 @@ from agent_env.utils.docker.lock import IN_IMAGE, Locked from agent_env.utils.docker.prune import Pruned -# The file an import records what it wrote in, and from where, for publishing the folder back. +# The file an import records what it wrote in, and from where, for registering the folder back. PROVENANCE = "provenance.json" +# Bumped when what the file records changes. +PROVENANCE_SCHEMA = 2 @dataclass(frozen=True) @@ -155,22 +161,48 @@ def size_text(size: int) -> str: return f"{size} B" if size < 1_000 else f"{size / 1_000:.1f} KB" if size < 1_000_000 else f"{size / 1_000_000:.1f} MB" -def provenance(source: dict, imported: BundleImport) -> str: - """What the folder was imported from and what it holds, by store id and version: no store's locations.""" +def provenance(source: dict, imported: BundleImport, root: Path) -> str: + """What the folder was imported from and what it holds, by store id and version, and each file written at ``root``, + so an edit can be told from what the store holds: no store's locations, only a fingerprint of the store's config.""" return json.dumps({ + "schema": PROVENANCE_SCHEMA, "agent_env": package_version("agentenv-framework"), "imported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "from": source, + "store": store_fingerprint(), + "tasks": [{"file": task.path, "id": task.id, "version": task.version} for task in imported.tasks], + "eval": {"file": imported.eval.path, "id": imported.eval.id, "version": imported.eval.version} + if imported.eval else None, "written": [{"folder": entity.folder, "kind": entity.kind.value, "type": entity.type, "id": entity.id, "version": entity.version} for entity in imported.written], "images": [{"folder": image.folder, "key": image.key, "id": image.artifact, "version": image.version, "dockerfile": image.pruned.dockerfile, "dockerfile_found": image.found} for image in imported.images], - "store": [{"where": pin.where, "kind": pin.kind.value, "id": pin.id, "version": pin.version, "why": pin.why} - for pin in imported.pinned], + "pinned": [{"where": pin.where, "kind": pin.kind.value, "id": pin.id, "version": pin.version, "why": pin.why} + for pin in imported.pinned], + "files": written_files(root), }, indent=2, ensure_ascii=False) + "\n" +def store_fingerprint() -> str: + """A hash of the configured document store's section, which tells one store from another without naming where + either is.""" + section = get_config().trace_section("document").value + text = json.dumps(section, sort_keys=True, separators=(",", ":"), default=str) + return "sha256:" + hashlib.sha256(text.encode()).hexdigest() + + +def written_files(root: Path) -> dict[str, str]: + """Each file under ``root`` but its README and provenance, by its path in NFC, as its content hash, ``+x`` after it + when it's executable.""" + files = {} + for path in sorted(root.rglob("*")): + relative = unicodedata.normalize("NFC", path.relative_to(root).as_posix()) + if path.is_file() and relative not in ("README.md", PROVENANCE): + files[relative] = file_sha256(path) + ("+x" if path.stat().st_mode & 0o111 else "") + return files + + def readme(description: str, imported: BundleImport) -> str: """The bundle's README: its description, where it came from, how to run and share it, what it holds, then the import's report.""" diff --git a/src/agent_env/bundle/importer.py b/src/agent_env/bundle/importer.py index c1c6d1f55..9339ed962 100644 --- a/src/agent_env/bundle/importer.py +++ b/src/agent_env/bundle/importer.py @@ -295,7 +295,6 @@ def write(self, description: str, source: dict) -> BundleImport: pinned=tuple(self.pinned), floating=tuple(self.floating), dropped=tuple(self.dropped), needs=tuple(self.needs), undeclared=tuple(self.undeclared), duplicates=tuple(self.duplicates), downloaded=downloaded) - self.files[PROVENANCE] = provenance(source, imported) self.files["README.md"] = readme(description, imported) try: for folder, (name, toml) in self.tomls.items(): @@ -304,6 +303,7 @@ def write(self, description: str, source: dict) -> BundleImport: for path, text in self.files.items(): (staging / path).parent.mkdir(parents=True, exist_ok=True) (staging / path).write_text(text, encoding="utf-8") + (staging / PROVENANCE).write_text(provenance(source, imported, staging), encoding="utf-8") except OSError as e: raise BundleError([f"{show(self.dest.parent)}: can't write the import there: {os_reason(e)}"]) from None check_bundle(resolve_bundle(parse_bundle(staging)), installed=installed_bundles) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 219c79d97..ef037eae8 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -31,6 +31,7 @@ from agent_env.store.local_state import holding_locks from agent_env.task.store import TASKS_COLLECTION +from ._fs import file_sha256 from .authoring import build_context_files from .parse import BundleKind from .plan import Plan, Write, artifact_writer, env_folder_writer, env_writer, folder_files, unpinned_store_refs @@ -127,7 +128,7 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | built = isinstance(write.source, BuiltImage) listing = build_context_files if built else folder_files for key, path in listing(self._plan.bundle_of(write.source.entry), write.source.entry).items(): - inputs["files"][key] = _file_sha256(path) + (_mode(path) if built else "") + inputs["files"][key] = file_sha256(path) + (_mode(path) if built else "") if (published := self._plan.published.get(write.id)) is not None: inputs["published"] = published.pinned if write.kind not in (BundleKind.TASK, BundleKind.EVAL): @@ -308,13 +309,5 @@ def _mode(path: Path) -> str: return "" if mode == _USUAL_MODE else f"+{mode:o}" -def _file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with open(path, "rb") as file: - for chunk in iter(lambda: file.read(1 << 20), b""): - digest.update(chunk) - return "sha256:" + digest.hexdigest() - - def _now() -> str: return datetime.now(timezone.utc).isoformat() diff --git a/tst/unit/bundle/importer_entities_test.py b/tst/unit/bundle/importer_entities_test.py index 64bc0a58a..e786a80da 100644 --- a/tst/unit/bundle/importer_entities_test.py +++ b/tst/unit/bundle/importer_entities_test.py @@ -2,6 +2,7 @@ stored build context, downloading a context stored as one object once; and what a folder can't hold fails the import, unless --include-scope user leaves it in the store, pinned.""" +import hashlib import json import os import tomllib @@ -19,6 +20,7 @@ from agent_env.artifact.artifacts.skill import SkillArtifact from agent_env.artifact.store import get_artifact_store from agent_env.bundle import BundleError +from agent_env.bundle._import_report import store_fingerprint from agent_env.bundle.importer import import_task from agent_env.bundle.resolve import build_step from agent_env.cli import cli @@ -83,7 +85,7 @@ def test_with_the_user_scope_it_stays_in_the_store_pinned_and_the_report_and_rea ("artifact", "crm-image", 1, "envs/crm: image")] readme = (imported.root / "README.md").read_text() assert "## Still needs the store" in readme and "except what *Still needs the store* lists" in readme - assert json.loads((imported.root / "provenance.json").read_text())["store"][0]["id"] == "crm-image" + assert json.loads((imported.root / "provenance.json").read_text())["pinned"][0]["id"] == "crm-image" def test_the_cli_says_up_front_that_the_folder_still_needs_the_store(home): @@ -274,14 +276,43 @@ def test_provenance_names_each_written_entity_and_image_by_store_id_and_version_ text = (imported.root / "provenance.json").read_text() provenance = json.loads(text) + assert provenance["schema"] == 2 assert provenance["from"] == {"task": "t", "version": 1} + assert provenance["tasks"] == [{"file": "tasks/t.json", "id": "t", "version": 1}] and provenance["eval"] is None assert provenance["written"] == [{"folder": "envs/crm", "kind": "env", "type": "mcp_server", "id": "crm", "version": 1}] assert provenance["images"] == [{"folder": "envs/crm", "key": "image", "id": "crm-image", "version": 1, "dockerfile": "Dockerfile", "dockerfile_found": False}] + assert provenance["store"] == store_fingerprint() and provenance["store"].startswith("sha256:") assert "file://" not in text and str(home) not in text +def test_provenance_holds_each_file_the_import_wrote_as_written_so_an_edit_can_be_told(home): + """Every file but the README and provenance itself, by its path in the folder, as its content hash, +x when + executable.""" + MCPServerEnv.put(id="crm", docker_image_artifact=context_image(home, "crm-image", { + "Dockerfile": "FROM python:3.12-slim\nCOPY run.sh /\n", "run.sh": "#!/bin/sh\n"}), environment_name="crm") + _task([DEPLOY]) + + imported = import_task("t", home / "t") + + files = json.loads((imported.root / "provenance.json").read_text())["files"] + on_disk = {path.relative_to(imported.root).as_posix() for path in imported.root.rglob("*") if path.is_file()} + assert set(files) == on_disk - {"README.md", "provenance.json"} + assert {"tasks/t.json", "envs/crm/env.toml", "envs/crm/Dockerfile"} <= set(files) + for path, digest in files.items(): + executable = os.access(imported.root / path, os.X_OK) + expected = "sha256:" + hashlib.sha256((imported.root / path).read_bytes()).hexdigest() + assert digest == expected + ("+x" if executable else "") + + +def test_the_store_fingerprint_tells_one_configured_document_store_from_another(home, tmp_path, monkeypatch): + here = store_fingerprint() + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "elsewhere")) + + assert store_fingerprint() != here and store_fingerprint() == store_fingerprint() + + def _saved_image(path, url, files): """A one-layer ``docker save`` tarball holding ``files``, stored at ``url``.""" path.write_bytes(saved_image([files], gz=True)) diff --git a/tst/unit/bundle/importer_test.py b/tst/unit/bundle/importer_test.py index e9ce9ba7c..3c7307857 100644 --- a/tst/unit/bundle/importer_test.py +++ b/tst/unit/bundle/importer_test.py @@ -288,6 +288,12 @@ def test_an_eval_imports_with_each_task_it_names_once_under_names_taken_from_the assert imported.eval == ImportedEval("evals/regression.toml", "nightly/regression", 1) plan = plan_bundle(resolve_bundle(parse_bundle(imported.root))) assert [entry.entry.name for entry in plan.evals] == ["regression"] + provenance = json.loads((imported.root / "provenance.json").read_text()) + assert provenance["eval"] == {"file": "evals/regression.toml", "id": "nightly/regression", "version": 1} + assert provenance["tasks"] == [{"file": "tasks/run.json", "id": "team/run", "version": 1}, + {"file": "tasks/run-2.json", "id": "other/run", "version": 1}, + {"file": "tasks/RUN-3.json", "id": "RUN", "version": 1}, + {"file": "tasks/triage.json", "id": "triage", "version": 1}] def test_an_eval_naming_a_task_the_store_doesnt_hold_is_refused_and_writes_nothing(store):