zcbot/core/artifact_lifecycle.py

221 lines
7.4 KiB
Python

"""Database-backed lifecycle operations for published workspace artifacts."""
from __future__ import annotations
import hashlib
import mimetypes
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from sqlalchemy import or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from .artifacts import ARTIFACT_TRASH_DIR, ArtifactRef
from .storage import session_scope
from .storage.models import Artifact
def _rel(root: Path, path: Path) -> str:
return Path(path).resolve().relative_to(Path(root).resolve()).as_posix()
def _hash_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def artifact_subtree_clause(path: str):
"""精确匹配文件/目录自身及其子树,并转义 LIKE 元字符。"""
escaped = path.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return or_(
Artifact.current_path == path,
Artifact.current_path.like(escaped + "/%", escape="\\"),
)
def register_published_artifacts(
*,
user_id: UUID,
task_id: UUID,
user_root: Path,
working_dir: Path,
refs: tuple[dict, ...],
) -> tuple[dict, ...]:
"""Upsert active artifact identities and return version-2 message refs."""
root = Path(user_root).resolve()
wd = Path(working_dir).resolve()
output: list[dict] = []
with session_scope() as session:
for ref in refs:
task_path = str(ref.get("path") or "")
path = (wd / Path(task_path)).resolve()
path.relative_to(wd)
if not path.is_file():
continue
current_path = _rel(root, path)
label = str(ref.get("label") or "")
media_type = str(ref.get("media_type") or "") or mimetypes.guess_type(path.name)[0]
size_bytes = path.stat().st_size
content_sha256 = _hash_file(path)
statement = pg_insert(Artifact).values(
user_id=user_id,
origin_task_id=task_id,
current_path=current_path,
label=label,
media_type=media_type,
size_bytes=size_bytes,
content_sha256=content_sha256,
).on_conflict_do_update(
index_elements=[Artifact.user_id, Artifact.current_path],
index_where=Artifact.status == "active",
set_={
"label": label,
"media_type": media_type,
"size_bytes": size_bytes,
"content_sha256": content_sha256,
"updated_at": datetime.now(timezone.utc),
},
).returning(Artifact.artifact_id)
artifact_id = session.execute(statement).scalar_one()
output.append(ArtifactRef(
path=task_path,
label=label,
artifact_id=artifact_id,
version=2,
).as_dict())
return tuple(output)
def rename_active_artifacts(
*,
user_id: UUID,
user_root: Path,
old_path: Path,
new_path: Path,
) -> int:
"""Rewrite active artifact paths for one file or directory subtree."""
root = Path(user_root).resolve()
old_rel = _rel(root, old_path)
new_rel = _rel(root, new_path)
with session_scope() as session:
rows = session.execute(
select(Artifact).where(
Artifact.user_id == user_id,
Artifact.status == "active",
artifact_subtree_clause(old_rel),
)
).scalars().all()
changed = 0
for row in rows:
if row.current_path == old_rel:
suffix = ""
elif row.current_path.startswith(old_rel + "/"):
suffix = row.current_path[len(old_rel):]
else:
continue
row.current_path = new_rel + suffix
changed += 1
return changed
def copy_active_artifacts(
*,
user_id: UUID,
user_root: Path,
source: Path,
target: Path,
) -> int:
"""Create independent artifact identities for copied files."""
root = Path(user_root).resolve()
source_rel = _rel(root, source)
target_rel = _rel(root, target)
with session_scope() as session:
sources = session.execute(
select(Artifact).where(
Artifact.user_id == user_id,
Artifact.status == "active",
artifact_subtree_clause(source_rel),
)
).scalars().all()
created = 0
for original in sources:
if original.current_path == source_rel:
suffix = ""
elif original.current_path.startswith(source_rel + "/"):
suffix = original.current_path[len(source_rel):]
else:
continue
copied_path = target_rel + suffix
session.add(Artifact(
user_id=user_id,
origin_task_id=original.origin_task_id,
copied_from_artifact_id=original.artifact_id,
current_path=copied_path,
label=(original.label + " 副本").strip(),
media_type=original.media_type,
size_bytes=original.size_bytes,
content_sha256=original.content_sha256,
))
created += 1
return created
def trash_active_artifacts(
*,
user_id: UUID,
user_root: Path,
target: Path,
) -> int:
"""Move matching active artifacts to hidden trash and mark them deleted."""
root = Path(user_root).resolve()
source = Path(target).resolve()
source_rel = _rel(root, source)
entry = (
root / ARTIFACT_TRASH_DIR
/ datetime.now(timezone.utc).strftime("%Y/%m/%d")
/ uuid4().hex
)
moved: list[tuple[Path, Path]] = []
try:
with session_scope() as session:
rows = session.execute(
select(Artifact).where(
Artifact.user_id == user_id,
Artifact.status == "active",
artifact_subtree_clause(source_rel),
).with_for_update()
).scalars().all()
matches = [
row for row in rows
if row.current_path == source_rel
or row.current_path.startswith(source_rel + "/")
]
if not matches:
return 0
for row in matches:
original = root / Path(row.current_path)
if not original.is_file():
continue
destination = entry / "files" / Path(row.current_path)
destination.parent.mkdir(parents=True, exist_ok=True)
os.replace(original, destination)
moved.append((original, destination))
row.status = "deleted"
row.deleted_at = datetime.now(timezone.utc)
row.trash_path = _rel(root, destination)
return len(moved)
except Exception:
for original, destination in reversed(moved):
try:
original.parent.mkdir(parents=True, exist_ok=True)
os.replace(destination, original)
except OSError:
pass
shutil.rmtree(entry, ignore_errors=True)
raise