zcbot/core/working_dirs.py

107 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 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)
)
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),
)