"""working_dir 的 DB-aware 文件系统变更。 网页 files API 与对话内延迟动作共用这里,保证顶层工作目录改名始终同时更新所有 关联 task,且不会与活跃 run 或 no-subtask 约束打架。 """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from uuid import UUID from sqlalchemy import select, update from .paths import to_db_path from .storage import NoSubtaskError, check_no_subtask, session_scope from .storage.models import Artifact, Task class WorkingDirRenameError(RuntimeError): """working_dir 无法安全改名。""" class WorkingDirConflictError(WorkingDirRenameError): """目录状态或 task 约束冲突,可由用户调整后重试。""" class WorkingDirBusyError(WorkingDirConflictError): """至少一个关联 task 仍有活跃 run。""" def __init__(self, task_ids: list[str]) -> None: self.task_ids = task_ids super().__init__( f"folder has active run(s) on task(s) {task_ids}; cancel before renaming" ) @dataclass(frozen=True) class WorkingDirRenameResult: old_path: Path new_path: Path tasks_updated: int def rename_working_dir( *, user_id: UUID, old_path: Path, new_path: Path, ) -> WorkingDirRenameResult: """原子地平移一个顶层 working_dir 的 DB 引用并重命名目录。 调用方负责用户边界与 leaf 名校验;本函数仍校验同级目录、源/目标状态和并发 task,作为网页路由与延迟动作共用的最后一道一致性闸。 顺序为 DB UPDATE → FS rename → transaction commit。FS 失败会回滚 DB;仍存在 极小的“FS 已成功但 PG commit 失败”窗口,与原 files API 的既有语义一致。 """ old = Path(old_path).resolve() new = Path(new_path).resolve() if old.parent != new.parent: raise WorkingDirRenameError("working_dir rename must stay under the same parent") if old == new: raise WorkingDirRenameError("new working_dir equals the old path") if not old.exists(): raise WorkingDirRenameError(f"working_dir not found: {old}") if not old.is_dir(): raise WorkingDirRenameError(f"working_dir is not a directory: {old}") if new.exists(): raise WorkingDirConflictError(f"target already exists: {new}") old_db = to_db_path(old) new_db = to_db_path(new) with session_scope() as s: rows = s.execute( select(Task.task_id, Task.run_status) .where(Task.user_id == user_id, Task.working_dir == old_db) .with_for_update() ).all() tids = [r.task_id for r in rows] active = [ str(r.task_id)[:8] for r in rows if r.run_status in ("running", "cancelling") ] if active: raise WorkingDirBusyError(active) try: check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids) except NoSubtaskError as e: raise WorkingDirConflictError(str(e)) from e if tids: s.execute( update(Task) .where(Task.task_id.in_(tids)) .values(working_dir=new_db) ) old_rel = old.relative_to(old.parent).as_posix() new_rel = new.relative_to(new.parent).as_posix() artifacts = s.execute( select(Artifact).where( Artifact.user_id == user_id, Artifact.status == "active", ) ).scalars().all() for artifact in artifacts: if artifact.current_path == old_rel: suffix = "" elif artifact.current_path.startswith(old_rel + "/"): suffix = artifact.current_path[len(old_rel):] else: continue artifact.current_path = new_rel + suffix try: old.rename(new) except OSError as e: raise WorkingDirRenameError(f"FS rename failed: {e}") from e return WorkingDirRenameResult( old_path=old, new_path=new, tasks_updated=len(tids), )