diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py index 627b34d7b..6d186de5f 100644 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py @@ -15,6 +15,7 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.core.observability import tracing_metrics_recording as _metrics from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.tracing.span_error import get_span_error from agentex.lib.core.tracing.processors.tracing_processor_interface import ( SyncTracingProcessor, AsyncTracingProcessor, @@ -83,6 +84,9 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: ), ) sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr] + error = get_span_error(span) + if error is not None: + sgp_span.set_error(error_type=error["type"], error_message=error["message"]) return sgp_span diff --git a/src/agentex/lib/core/tracing/span_error.py b/src/agentex/lib/core/tracing/span_error.py new file mode 100644 index 000000000..508c5e800 --- /dev/null +++ b/src/agentex/lib/core/tracing/span_error.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +from agentex.types.span import Span + +# Reserved key under ``Span.data`` carrying failure info for a span whose +# context-manager body raised. Mirrors the existing ``__span_type__`` / +# ``__source__`` reserved-key convention already read/written by the SGP +# processor. Stored in ``data`` because the Span model is generated from the +# OpenAPI spec and has no first-class status/error field; ``data`` is a real +# field, so it survives ``model_copy(deep=True)`` and round-trips to both the +# SGP and agentex-native span stores. +SPAN_ERROR_KEY = "__error__" + + +def set_span_error(span: Span, exc: BaseException) -> None: + """Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``. + + No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which + only attaches metadata to dict-shaped data). + """ + error = {"type": type(exc).__name__, "message": str(exc)} + if span.data is None: + span.data = {} + if isinstance(span.data, dict): + span.data[SPAN_ERROR_KEY] = error + + +def get_span_error(span: Span) -> dict[str, Any] | None: + """Return the error recorded by :func:`set_span_error`, or ``None``.""" + if isinstance(span.data, dict): + value = span.data.get(SPAN_ERROR_KEY) + if isinstance(value, dict): + return value + return None diff --git a/src/agentex/lib/core/tracing/trace.py b/src/agentex/lib/core/tracing/trace.py index 70b268b18..a22bfd658 100644 --- a/src/agentex/lib/core/tracing/trace.py +++ b/src/agentex/lib/core/tracing/trace.py @@ -11,6 +11,7 @@ from agentex.types.span import Span from agentex.lib.utils.logging import make_logger from agentex.lib.utils.model_utils import recursive_model_dump +from agentex.lib.core.tracing.span_error import set_span_error from agentex.lib.core.tracing.span_queue import ( SpanEventType, AsyncSpanQueue, @@ -165,6 +166,9 @@ def span( span = self.start_span(name, parent_id, input, data, task_id=task_id) try: yield span + except Exception as exc: + set_span_error(span, exc) + raise finally: self.end_span(span) @@ -321,5 +325,8 @@ async def span( span = await self.start_span(name, parent_id, input, data, task_id=task_id) try: yield span + except Exception as exc: + set_span_error(span, exc) + raise finally: await self.end_span(span) diff --git a/src/agentex/lib/sdk/config/agent_manifest.py b/src/agentex/lib/sdk/config/agent_manifest.py index fd743e635..c2fe03052 100644 --- a/src/agentex/lib/sdk/config/agent_manifest.py +++ b/src/agentex/lib/sdk/config/agent_manifest.py @@ -24,6 +24,7 @@ from agentex.lib.utils.io import load_yaml_file from agentex.lib.utils.logging import make_logger from agentex.config.agent_manifest import AgentManifest # noqa: F401 +from agentex.lib.utils.build_provenance import iter_context_files logger = make_logger(__name__) @@ -189,12 +190,11 @@ def zipped(root_path: Path | None = None) -> Iterator[IO[bytes]]: tar_buffer = io.BytesIO() + # Sorted, relpath-stable enumeration (shared with the content hash) so the + # archive's member order is deterministic across machines. with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar_file: - for path in Path(root_path).rglob( - "*" - ): # Recursively add files to the tar.gz - if path.is_file(): # Ensure that we're only adding files - tar_file.add(path, arcname=path.relative_to(root_path)) + for path in iter_context_files(Path(root_path)): + tar_file.add(path, arcname=path.relative_to(root_path)) tar_buffer.seek(0) # Reset the buffer position to the beginning yield tar_buffer diff --git a/src/agentex/lib/utils/build_provenance.py b/src/agentex/lib/utils/build_provenance.py new file mode 100644 index 000000000..447980263 --- /dev/null +++ b/src/agentex/lib/utils/build_provenance.py @@ -0,0 +1,189 @@ +"""Capture client-attested source identity without failing agent builds.""" + +from __future__ import annotations + +import os +import stat +import hashlib +import subprocess +from typing import Optional +from pathlib import Path +from datetime import datetime, timezone +from dataclasses import dataclass + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +_GIT_TIMEOUT_S = 5 +_HASH_CHUNK_BYTES = 1 << 20 + + +@dataclass(frozen=True) +class BuildProvenance: + """Source identity for one build; unavailable fields degrade to ``None``.""" + + repo: Optional[str] = None + commit: Optional[str] = None + ref: Optional[str] = None + subpath: Optional[str] = None + working_tree_hash: Optional[str] = None + dirty: Optional[bool] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + build_timestamp: Optional[str] = None + + def source_fields(self) -> dict[str, object]: + """The ``source_*`` form fields for the cloud-build upload (None omitted).""" + fields = { + "source_repo": self.repo, + "source_commit": self.commit, + "source_ref": self.ref, + "source_subpath": self.subpath, + "working_tree_hash": self.working_tree_hash, + "source_dirty": self.dirty, + } + return {key: value for key, value in fields.items() if value is not None} + + def build_info(self) -> dict[str, object]: + """Return provenance using the runtime registration metadata field names.""" + info = { + "repo": self.repo, + "commit_hash": self.commit, + "branch_name": self.ref, + "subpath": self.subpath, + "working_tree_hash": self.working_tree_hash, + "dirty": self.dirty, + "author_name": self.author_name, + "author_email": self.author_email, + "build_timestamp": self.build_timestamp, + } + return {key: value for key, value in info.items() if value is not None} + + +def _git(repo_root: Path, *args: str) -> Optional[str]: + """Run a git command under ``repo_root``; return stripped stdout or None.""" + try: + proc = subprocess.run( + ("git", "-C", str(repo_root), *args), + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT_S, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + return proc.stdout.strip() or None + + +def normalize_remote(url: Optional[str]) -> Optional[str]: + """Strip credentials and scheme from a remote, returning ``host/path``.""" + if not url: + return None + candidate = url.strip() + # scp-like syntax: git@host:org/repo(.git) — no scheme, host/path split on ':' + if "://" not in candidate and ":" in candidate and "/" not in candidate.split(":", 1)[0]: + candidate = candidate.split("@", 1)[-1].replace(":", "/", 1) + else: + if "://" in candidate: + candidate = candidate.split("://", 1)[1] + candidate = candidate.split("@", 1)[-1] + if candidate.endswith(".git"): + candidate = candidate[: -len(".git")] + candidate = candidate.strip("/") + if not candidate: + return None + host, slash, path = candidate.partition("/") + return f"{host.lower()}{slash}{path}" + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(_HASH_CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +def iter_context_files(root: Path) -> list[Path]: + """Return files and symlinks under ``root``, sorted by POSIX relative path.""" + return sorted( + (path for path in root.rglob("*") if path.is_symlink() or path.is_file()), + key=lambda path: path.relative_to(root).as_posix(), + ) + + +def working_tree_hash(root: Path) -> str: + """Hash sorted build inputs, normalized modes, and symlink target strings.""" + lines: list[str] = [] + for path in iter_context_files(root): + relpath = path.relative_to(root).as_posix() + if path.is_symlink(): + mode = "120000" + content_digest = hashlib.sha256(os.readlink(path).encode("utf-8")).hexdigest() + else: + executable = bool(path.stat().st_mode & stat.S_IXUSR) + mode = "100755" if executable else "100644" + content_digest = _sha256_file(path) + lines.append(f"{relpath}\x00{mode}\x00{content_digest}") + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +def _safe_working_tree_hash(root: Path) -> Optional[str]: + """Compute the context hash without allowing capture to fail a build.""" + try: + return working_tree_hash(root) + except Exception: + logger.warning("build-provenance: content hash failed; omitting", exc_info=True) + return None + + +def capture_build_provenance( + repo_path: Path, context_root: Path, content_root: Optional[Path] = None +) -> BuildProvenance: + """Capture git coordinates and the staged build-context hash.""" + timestamp = datetime.now(timezone.utc).isoformat() + hash_root = content_root if content_root is not None else context_root + tree_hash = _safe_working_tree_hash(hash_root) + + repo_root = _git(repo_path, "rev-parse", "--show-toplevel") + if repo_root is None: + # No git — the content hash is the only identity available. + logger.info("build-provenance: %s is not a git work tree; content hash only", repo_path) + return BuildProvenance(working_tree_hash=tree_hash, build_timestamp=timestamp) + + repo_root_path = Path(repo_root) + commit = _git(repo_root_path, "rev-parse", "HEAD") + # symbolic-ref fails on a detached HEAD (→ None); fall back to an exact tag. + ref = _git(repo_root_path, "symbolic-ref", "--short", "HEAD") or _git( + repo_root_path, "describe", "--tags", "--exact-match" + ) + remote = normalize_remote(_git(repo_root_path, "remote", "get-url", "origin")) + author_name = _git(repo_root_path, "log", "-1", "--format=%an") + author_email = _git(repo_root_path, "log", "-1", "--format=%ae") + + subpath: Optional[str] = None + try: + relative = context_root.resolve().relative_to(repo_root_path.resolve()).as_posix() + subpath = relative if relative != "." else None + except ValueError: + subpath = None + + status_args = ("status", "--porcelain") + if subpath is not None: + status_args += ("--", subpath) + dirty = _git(repo_root_path, *status_args) is not None + + return BuildProvenance( + repo=remote, + commit=commit, + ref=ref, + subpath=subpath, + working_tree_hash=tree_hash, + dirty=dirty, + author_name=author_name, + author_email=author_email, + build_timestamp=timestamp, + ) diff --git a/tests/lib/core/tracing/test_span_error.py b/tests/lib/core/tracing/test_span_error.py new file mode 100644 index 000000000..18116dcb3 --- /dev/null +++ b/tests/lib/core/tracing/test_span_error.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import uuid +from typing import Any +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from agentex.types.span import Span +from agentex.lib.core.tracing.trace import Trace, AsyncTrace +from agentex.lib.core.tracing.span_error import ( + SPAN_ERROR_KEY, + get_span_error, + set_span_error, +) + +PROCESSOR_MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" + + +def _make_span(data=None) -> Span: + return Span( + id=str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + data=data, + ) + + +# --------------------------------------------------------------------------- +# Helpers: set_span_error / get_span_error +# --------------------------------------------------------------------------- + + +class TestSpanErrorHelpers: + def test_set_then_get_on_none_data(self): + span = _make_span(data=None) + set_span_error(span, ValueError("boom")) + assert get_span_error(span) == {"type": "ValueError", "message": "boom"} + assert isinstance(span.data, dict) + assert span.data[SPAN_ERROR_KEY] == {"type": "ValueError", "message": "boom"} + + def test_set_preserves_existing_dict_keys(self): + span = _make_span(data={"__span_type__": "LLM"}) + set_span_error(span, RuntimeError("nope")) + assert isinstance(span.data, dict) + assert span.data["__span_type__"] == "LLM" + err = get_span_error(span) + assert err is not None + assert err["type"] == "RuntimeError" + + def test_get_returns_none_when_no_error(self): + assert get_span_error(_make_span(data={"foo": "bar"})) is None + assert get_span_error(_make_span(data=None)) is None + + def test_set_is_noop_on_list_data(self): + span = _make_span(data=[{"a": 1}]) + set_span_error(span, ValueError("boom")) + # list-shaped data is left untouched (mirrors _add_source_to_span) + assert span.data == [{"a": 1}] + assert get_span_error(span) is None + + +# --------------------------------------------------------------------------- +# Capture: the context managers record body exceptions onto the span +# --------------------------------------------------------------------------- + + +class TestContextManagerCapture: + def test_sync_span_records_error_and_reraises(self): + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") + captured = {} + with pytest.raises(ValueError, match="boom"): + with trace.span("op") as span: + captured["span"] = span + raise ValueError("boom") + err = get_span_error(captured["span"]) + assert err == {"type": "ValueError", "message": "boom"} + + def test_sync_span_success_has_no_error(self): + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") + with trace.span("op") as span: + pass + assert get_span_error(span) is None + + @pytest.mark.asyncio + async def test_async_span_records_error_and_reraises(self): + trace = AsyncTrace(processors=[], client=MagicMock(), trace_id="t1") + captured = {} + with pytest.raises(RuntimeError, match="kaboom"): + async with trace.span("op") as span: + captured["span"] = span + raise RuntimeError("kaboom") + err = get_span_error(captured["span"]) + assert err == {"type": "RuntimeError", "message": "kaboom"} + + +# --------------------------------------------------------------------------- +# Map: _build_sgp_span translates the recorded error into SGP status=ERROR +# --------------------------------------------------------------------------- + + +class _FakeSGPSpan: + def __init__(self, metadata: dict[str, Any] | None) -> None: + self.status = "SUCCESS" + self.metadata: dict[str, Any] = metadata if metadata is not None else {} + self.start_time = None + + def set_error( + self, + error_type: str | None = None, + error_message: str | None = None, + exception: BaseException | None = None, + ) -> None: + self.status = "ERROR" + self.metadata["error"] = True + self.metadata["error_type"] = error_type + self.metadata["error_message"] = error_message + + +def _fake_create_span(**kwargs: Any) -> _FakeSGPSpan: + return _FakeSGPSpan(kwargs.get("metadata")) + + +class TestBuildSGPSpanMapping: + @staticmethod + def _env(): + return MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) + + def test_error_maps_to_status_error(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span + + span = _make_span(data={SPAN_ERROR_KEY: {"type": "ValueError", "message": "boom"}}) + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): + sgp_span = _build_sgp_span(span, self._env()) + + assert sgp_span.status == "ERROR" + assert sgp_span.metadata["error"] is True + assert sgp_span.metadata["error_type"] == "ValueError" + assert sgp_span.metadata["error_message"] == "boom" + + def test_no_error_leaves_status_success(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span + + span = _make_span(data={"__span_type__": "LLM"}) + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): + sgp_span = _build_sgp_span(span, self._env()) + + assert sgp_span.status == "SUCCESS" + assert "error" not in sgp_span.metadata diff --git a/tests/lib/test_build_provenance.py b/tests/lib/test_build_provenance.py new file mode 100644 index 000000000..9115e2804 --- /dev/null +++ b/tests/lib/test_build_provenance.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from agentex.lib.utils.build_provenance import ( + normalize_remote, + working_tree_hash, + iter_context_files, + capture_build_provenance, +) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(("git", "-C", str(repo), *args), check=True, capture_output=True, text=True) + + +def _init_repo(path: Path, *, remote: str | None = "git@github.com:scaleapi/demo.git") -> Path: + path.mkdir(parents=True, exist_ok=True) + _git(path, "init", "-q") + _git(path, "config", "user.email", "dev@scale.com") + _git(path, "config", "user.name", "Dev") + _git(path, "config", "commit.gpgsign", "false") + if remote: + _git(path, "remote", "add", "origin", remote) + return path + + +def _commit_all(path: Path, message: str = "init") -> None: + _git(path, "add", "-A") + _git(path, "commit", "-q", "-m", message) + _git(path, "branch", "-M", "main") + + +def _write(root: Path, rel: str, content: str = "x") -> None: + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + +# --- normalize_remote --------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("git@github.com:scaleapi/Repo.git", "github.com/scaleapi/Repo"), + ("https://github.com/scaleapi/Repo.git", "github.com/scaleapi/Repo"), + ("https://x-token:secret@GitHub.com/scaleapi/Repo", "github.com/scaleapi/Repo"), + ("ssh://git@gitlab.com/group/sub/proj.git", "gitlab.com/group/sub/proj"), + ("", None), + (None, None), + ], +) +def test_normalize_remote(raw: str | None, expected: str | None) -> None: + assert normalize_remote(raw) == expected + + +# --- working_tree_hash -------------------------------------------------------- + + +def test_hash_is_order_independent(tmp_path: Path) -> None: + first = tmp_path / "a" + second = tmp_path / "b" + for rel in ("z.txt", "a/b.txt", "m.txt"): + _write(first, rel, rel) + # Same content, different creation order. + for rel in ("m.txt", "z.txt", "a/b.txt"): + _write(second, rel, rel) + assert working_tree_hash(first) == working_tree_hash(second) + + +def test_hash_changes_on_one_byte(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "f.txt", "hello") + before = working_tree_hash(root) + _write(root, "f.txt", "hellp") + assert working_tree_hash(root) != before + + +def test_hash_changes_when_file_added(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "f.txt", "hello") + before = working_tree_hash(root) + _write(root, "g.txt", "new") + assert working_tree_hash(root) != before + + +def test_hash_changes_on_executable_bit(tmp_path: Path) -> None: + root = tmp_path / "ctx" + script = root / "run.sh" + _write(root, "run.sh", "#!/bin/sh\n") + before = working_tree_hash(root) + script.chmod(0o755) + assert working_tree_hash(root) != before + + +def test_symlink_hashes_target_not_resolved_content(tmp_path: Path) -> None: + root = tmp_path / "ctx" + root.mkdir() + # Dangling symlinks: distinct hashes prove the target string is hashed, not + # resolved content (resolving would raise). + (root / "link").symlink_to("points/to/a") + hash_a = working_tree_hash(root) + (root / "link").unlink() + (root / "link").symlink_to("points/to/b") + assert working_tree_hash(root) != hash_a + + +def test_iter_context_files_skips_directories(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "pkg/mod.py", "x") + _write(root, "top.txt", "y") + rels = [path.relative_to(root).as_posix() for path in iter_context_files(root)] + assert rels == ["pkg/mod.py", "top.txt"] + + +# --- capture_build_provenance ------------------------------------------------- + + +def test_capture_clean_tree(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo) + + assert prov.repo == "github.com/scaleapi/demo" + assert prov.ref == "main" + assert prov.commit is not None and len(prov.commit) == 40 + assert prov.working_tree_hash is not None # always computed + assert prov.dirty is False + assert prov.subpath is None + assert prov.author_email == "dev@scale.com" + + +def test_capture_untracked_file_changes_hash(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _write(repo, "scratch.py", "debug = True") # untracked + + prov = capture_build_provenance(repo, repo) + + # The stale-code guard: an untracked file is part of the build context, so it + # must move the hash (a `git diff` of tracked files alone would miss it). + assert prov.dirty is True + assert prov.working_tree_hash == working_tree_hash(repo) + assert working_tree_hash(repo) != _hash_without(repo, "scratch.py") + + +def _hash_without(repo: Path, rel: str) -> str: + removed = repo / rel + saved = removed.read_text() + removed.unlink() + try: + return working_tree_hash(repo) + finally: + removed.write_text(saved) + + +def test_capture_detached_head_has_no_ref(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _write(repo, "main.py", "print(2)") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "second") + first = subprocess.run( + ("git", "-C", str(repo), "rev-list", "--max-parents=0", "HEAD"), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + _git(repo, "checkout", "-q", first) + + prov = capture_build_provenance(repo, repo) + + assert prov.commit == first + assert prov.ref is None + + +def test_capture_detached_on_tag_uses_tag(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _git(repo, "tag", "v1.2.3") + _git(repo, "checkout", "-q", "v1.2.3") + + assert capture_build_provenance(repo, repo).ref == "v1.2.3" + + +def test_capture_no_remote(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo", remote=None) + _write(repo, "main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo) + + assert prov.repo is None + assert prov.commit is not None + assert prov.working_tree_hash is not None # always computed + + +def test_capture_non_git_dir(tmp_path: Path) -> None: + plain = tmp_path / "plain" + _write(plain, "main.py", "print(1)") + + prov = capture_build_provenance(plain, plain) + + assert prov.repo is None + assert prov.commit is None + assert prov.ref is None + # No commit → the content hash is the identity; dirtiness is undefined (no VCS). + assert prov.working_tree_hash == working_tree_hash(plain) + assert prov.dirty is None + assert prov.build_timestamp is not None + + +def test_capture_never_raises_when_hash_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import agentex.lib.utils.build_provenance as bp + + plain = tmp_path / "plain" # non-git → would hash, which we force to fail + _write(plain, "main.py", "print(1)") + + def _boom(_root: Path) -> str: + raise OSError("permission denied") + + monkeypatch.setattr(bp, "working_tree_hash", _boom) + + prov = bp.capture_build_provenance(plain, plain) # must not raise + + assert prov.working_tree_hash is None + + +def test_capture_monorepo_subpath(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "agents/foo/main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo / "agents" / "foo") + + assert prov.subpath == "agents/foo" + + +def test_capture_monorepo_ignores_changes_outside_context(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "agents/foo/main.py", "print(1)") + _write(repo, "agents/bar/main.py", "print(2)") + _commit_all(repo) + _write(repo, "agents/bar/scratch.py", "debug = True") + + prov = capture_build_provenance(repo, repo / "agents" / "foo") + + assert prov.dirty is False