160 lines
4.9 KiB
Python
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())
|