123 lines
4.1 KiB
Python
123 lines
4.1 KiB
Python
"""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),
|
||
)
|