zcbot/core/artifacts.py

160 lines
4.9 KiB
Python

"""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 (``<wd>/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())