"""Artifact message references and working-dir scoped path resolution. Files remain the content source of truth. Version-2 refs carry a stable database identity plus a task-relative path snapshot; version-1 path-only refs remain readable. """ from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Optional from uuid import UUID ARTIFACT_REF_VERSION = 1 ARTIFACT_REF_CURRENT_VERSION = 2 MAX_ARTIFACTS_PER_MESSAGE = 10 _CONTAINER_ROOT = Path("/workspace") ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash" class ArtifactPathError(ValueError): """A proposed artifact path is invalid or outside the current working_dir.""" @dataclass(frozen=True) class ArtifactRef: path: str label: str = "" artifact_id: Optional[UUID] = None scope: str = "working_dir" version: int = ARTIFACT_REF_VERSION def as_dict(self) -> dict: out = { "version": self.version, "scope": self.scope, "path": self.path, } if self.artifact_id: out["artifact_id"] = str(self.artifact_id) if self.label: out["label"] = self.label return out class ToolExecutionResult(str): """String-compatible rich result for tools that publish deliverables. A few internal scripts and tests call tools directly and historically received a plain string. Subclassing ``str`` preserves that contract while executors can still consume the structured artifact metadata. """ content: str artifacts: tuple[ArtifactRef, ...] def __new__( cls, content: str, artifacts: Iterable[ArtifactRef] = (), ) -> "ToolExecutionResult": obj = super().__new__(cls, content) obj.content = content obj.artifacts = tuple(artifacts) return obj def _relative_parts(path: Path) -> tuple[str, ...]: return tuple(part for part in path.parts if part not in ("", ".")) def resolve_artifact_path( raw_path: str, *, working_dir: Path, user_root: Path, require_file: bool = True, allow_legacy_user_relative: bool = True, ) -> tuple[Path, str]: """Resolve legacy/canonical input and return (absolute, task-relative POSIX path). Canonical input is relative to working_dir (``reports/a.pdf``). For compatibility, user-root-relative paths (``/reports/a.pdf``), container absolute paths under ``/workspace`` and host absolute paths inside working_dir are accepted too. """ raw = str(raw_path or "").strip().replace("\\", "/") if not raw or "\x00" in raw: raise ArtifactPathError("artifact path is empty or contains NUL") wd = Path(working_dir).resolve() root = Path(user_root).resolve() try: wd_rel = wd.relative_to(root) except ValueError as exc: raise ArtifactPathError("working_dir is outside user_root") from exc explicit_task_relative = raw.startswith("./") p = Path(raw[2:] if explicit_task_relative else raw) if raw == "/workspace" or raw.startswith("/workspace/"): rest = raw[len("/workspace"):].lstrip("/") candidate = root / Path(rest) elif p.is_absolute(): candidate = p else: parts = _relative_parts(p) wd_parts = _relative_parts(wd_rel) if ( allow_legacy_user_relative and not explicit_task_relative and wd_parts and parts[:len(wd_parts)] == wd_parts ): candidate = root.joinpath(*parts) else: candidate = wd.joinpath(*parts) resolved = candidate.resolve() try: rel = resolved.relative_to(wd) except ValueError as exc: raise ArtifactPathError("artifact path escapes working_dir") from exc if rel == Path("."): raise ArtifactPathError("artifact path must reference a file") if require_file and not resolved.is_file(): raise ArtifactPathError(f"artifact file not found: {rel.as_posix()}") return resolved, rel.as_posix() def normalize_artifact_refs(refs: Iterable[ArtifactRef]) -> list[dict]: """Deduplicate validated refs while preserving order and enforcing the UI limit.""" out: list[dict] = [] seen: set[tuple[str, str]] = set() for ref in refs: if ref.scope != "working_dir" or ref.version not in ( ARTIFACT_REF_VERSION, ARTIFACT_REF_CURRENT_VERSION, ): continue key = (ref.scope, ref.path) if key in seen: continue seen.add(key) out.append(ref.as_dict()) if len(out) >= MAX_ARTIFACTS_PER_MESSAGE: break return out def artifact_ref_for_file( path: Path, *, working_dir: Path, user_root: Path, label: Optional[str] = None, ) -> ArtifactRef: _, rel = resolve_artifact_path( str(path), working_dir=working_dir, user_root=user_root, require_file=True, ) return ArtifactRef(path=rel, label=(label or "").strip())