core(/v1/files): 加 rename + delete 顶层加 task 引用闸

- POST /v1/files/rename:任意深度;path 是顶层目录则 DB-aware
  (FOR UPDATE 锁 task / 活跃 run 互锁 / check_no_subtask exclude /
  UPDATE working_dir 先于 FS rename,FS 失败回滚)
- POST /v1/files/delete:顶层目录 + 有 task 引用 → 409,杜绝悬空
- check_no_subtask 加 exclude_task_ids,rename 平移自己不误判嵌套
- dev SPA:file row 加改名按钮,顶层改名后刷任务列表 + 当前 task header
- smoke 7 case 全绿(scripts/smoke_files_rename.py)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 21:06:21 +08:00
parent 49be5e01e4
commit 9aa2efc335
6 changed files with 344 additions and 8 deletions

View File

@ -21,6 +21,7 @@
## 已完成关键能力
- **05-18 / `POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:用户反复抠"文件夹改名 / 删除时怎么不破 DB 一致性"。架构最终落点:**`/v1/files/*` 是唯一的目录树 mutation 命名空间,DB-FS 一致性作为服务端不变量内化**(放弃曾经的"files API 永不进 DB"惯例 —— 那是当初没考虑顶层目录时形成的偶然,把它升格成铁律反而导出双命名空间代价);`GET /v1/folders` 保留,但定位为"项目聚合视图"(只读,带 n_tasks/last_used,新建任务 datalist 用),不做 mutation。**判定**:`target.parent.resolve() == root.resolve() and target.is_dir()` ⇒ 顶层目录(就是 task 的 working_dir)。**新 `POST /v1/files/rename`**:校验 `validate_task_name(new_name)` / target 存在 / 不能等于 user_root / sibling 不能已存在;**顶层目录**走 DB-aware 分支:`session_scope()` 事务内 `SELECT task_id, run_status WHERE working_dir=old_db FOR UPDATE` 锁所有关联 task,任一 `run_status in ('running','cancelling')` → 409;`check_no_subtask(new_db, exclude_task_ids=tids)` 防改名后与其它 task 形成嵌套(exclude 平移过去的自己);`UPDATE tasks SET working_dir=new_db` → `os.rename(old_fs, new_fs)` —— FS 失败 raise → session_scope 回滚 DB UPDATE。**非顶层**(子目录 / 文件)纯 FS rename,不动 DB。**事务顺序考量**:DB UPDATE 在 FS rename 之前(都在事务未提交期间),FS 失败可回滚 UPDATE;唯一不一致窗口是"FS 改完 + commit 失败"(PG 单事务 commit 极少失败,接受)。**`POST /v1/files/delete` 收紧**:同样的顶层目录判定,若顶层目录有任意 task 引用 → 409 "请先 DELETE 关联 task 再删目录",避免悬空指针。**`check_no_subtask` 扩 `exclude_task_ids` 参数**:`core/storage/utils.py` 加可选 Iterable[UUID],循环里跳过这些 task_id;rename 场景刚需(否则被改名 task 与自己未来的 new_db 误判为嵌套);其它 caller 默认 None 行为不变。**dev SPA 同步**(`web/static/dev.html`):file row 加 `改名` 按钮,prompt 拿新名 → POST `/v1/files/rename`;rename 后:① 当前 `state.filesPath` 若在被改名子树内做前缀替换继续停留(`rel === filesPath` 或 `filesPath.startsWith(rel + "/")` → 替换前缀为 res.new);② `loadFolderSuggestions()` 刷 datalist;③ `res.tasks_updated > 0``loadTaskList()` + `selectTask(state.taskId)`(task 卡片 / chat 头里展示的 working_dir 末段也跟着变)。delete confirm 文案补一句"顶层目录且仍被 task 引用需先删 task";删除完成也 `loadFolderSuggestions()`。**Smoke 5 case 全绿**(in-process TestClient + PG):① 子目录 rename 纯 FS / tasks_updated=0;② 顶层目录 rename 同步 UPDATE / tasks_updated=N / FS 改完 + DB working_dir 跟着变;③ 顶层目录 rename 时有 running task → 409;④ 删顶层有 task 引用 → 409;⑤ rename 目标已存在 → 409。**Smoke 文件**(`scripts/smoke_files_rename.py`)跑完未删(留作回归用)。**没动**:`GET /v1/folders` 接口、`DELETE /v1/tasks/{id}` 行为(仍删 DB 行不动 FS,与新 delete 配对刚好覆盖"销毁项目"全链路);`/v1/files/{list,upload,download}` 路由签名;skill / chat / cancel 等其它路由。**架构反思**:此前一版我先提的双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 if path is top-level 切分支被自己视为"代码异味" —— 实际是反了,这种分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。这条工程教训记 §7.9。
- **05-18 / system prompt skill 机制改"可选辅助"**:接 `GET /v1/skills` + 下拉选择落地后,task 创建时 skill 字段允许留空成为常态。原 `prompts/system/general_v1.md` 第 14 行 `"永远 load 一下。skill 数有限,加载成本很低"` 在新形态下变得过激 —— 简单问答 / 通用编码 / 文件操作不该被强行匹配到 coding 等 skill。改为"Skill 是**可选辅助**"+ 明确列出"简单问答、读代码、改 bug、文件操作这类通用任务,直接用通用工具就够,不必为每个任务硬套 skill"。一旦决定要用仍要求 load 完整指引(原则不变)。**未动**:skill discovery block 内容(name + description 注入仍按 registry 顺序)、`load_skill` 工具协议、SKILL.md 内容。**tradeoff**:边缘场景(用户提"整理大纲"可能落 proposal 也可能不用)agent 现在会偏向不 load,可能漏掉好的模板;但比原来"什么都套 coding"的噪音更可接受。
- **05-18 / `GET /v1/skills` + dev SPA skill 字段改下拉**:原 `nt-skill` 是自由输入(用户得记住 `coding / ppt / proposal` 拼写),用户反馈"加 skill 接口给前端选"。后端 `web/app.py` lifespan 启动时 `SkillRegistry(ROOT / cfg["skills_dir"])` 扫一次挂到 `app.state.skill_registry`(文件系统静态,运行中不变);新增 `GET /v1/skills``require_user` JWT 鉴权,返 `{skills:[{name,description}]}` 按 name 升序(registry 已 sorted)。dev SPA(`web/static/dev.html`):`<input id=nt-skill>` 换 `<select>`,首项固定 `(默认 · 不限定)` 空值;`hd-new` 打开 modal 时 `loadSkillOptions()``loadFolderSuggestions()` 并发(`Promise.all`),首次拉到的列表缓存到 `state.skills`,失败时静默退化为只剩"默认"项不阻塞。option 文案 `name — description`,`title` 也带 description 鼠标悬停看长文。Smoke:`TestClient` 起 app → `/v1/auth/login` 拿 token → `/v1/skills` 返 3 项(coding/ppt/proposal)+ 描述;无 token 401。**未动**:`_build_system_prompt` 注入的 skill discovery block(name + description)和这里渲染的下拉项是同源 registry,改一处不影响另一处;`POST /v1/tasks` body 不校验 `skill ∈ registry`(留空 / 任意串都允许,与 schema 一致 — 真要拦在 UI 层早就拦了)。
- **05-18 / dev SPA 全套 UI 中文化**:用户反馈"web 页面菜单按钮啥的改为中文"。`web/static/dev.html` 静态部分(login overlay / header / 三栏 pane-head label / chat 操作按钮 / new task modal)+ JS 动态部分(状态文案 / role 标签 / confirm/alert 文案 / 状态 badge / SSE 流式提示)全面本地化。**静态文案**:`zcbot dev login → zcbot 登录` / `+ new task → + 新建任务` / `logout → 退出登录` / `tasks/chat/files → 任务/对话/文件` / 状态 select `(all)/active/completed/abandoned → (全部)/进行中/已完成/已废弃` / `export .docx/done/abandon/delete → 导出 docx/完成/废弃/删除` / `stop/send → 停止/发送` / `ready/sending/streaming/cancelling → 就绪/发送中/接收中/停止中` / `(no task selected) → (未选中任务)` / `select a task on the left → 请在左侧选一个任务` / `loading… → 加载中…` / `load failed → 加载失败` / `(no tasks) → (暂无任务)` / `(no messages yet) → (暂无消息 · 在下方输入开始对话)` / `(unnamed) → (未命名)` / `(user root) → (根目录)`。**动态文案**:`renderTaskList` / `renderChatMeta``statusLabels` map(`active→进行中`等),task list 计数 `msg → 条`;消息卡 role 标签 `user/assistant/error → 我/助手/错误`,`tool · name → 工具调用 · name`,`result (N chars) → 结果(N 字符)`,SSE 流式 `tool_call:/tool_result → 工具调用:/工具结果`;`cancelled` badge `已停止(stopped by user) → 已停止`(更简洁)。**弹窗 / 错文案**:`确认置为 status? → 确认置为「中文 label」?` / `delete failed → 删除失败:` / `download failed → 下载失败:` / `upload failed → 上传失败:` / `export failed → 导出失败:` / 删 task confirm 文案改"任务「项目名」(N 条消息)" / `任务名 必填 → 任务名为必填项`。**modal**:`新建 task → 新建任务` / 各 label "必填"/"可选" 加括号统一 / `留空 fallback 用任务名 → 留空则用任务名` / `N 个 task → N 个任务`。**Smoke**(in-process TestClient 拉 `/static/dev.html`):assert 13 个中文标签全在 + 8 个原英文按钮文案全无残留。**没动**:技术字段(`user_id` / `platform_key` / `UUID` / `tok` token 简称)、CSS class(`badge active` 等仍是英文 class,但显示文本走 statusLabels)、SSE event 名(`text/tool_call/tool_result/done/error/cancelled`)、API 字段名 — 都是 schema 层,不影响 UI 中文。

12
RUN.md
View File

@ -107,10 +107,11 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`tasks.run_status` 是 running / cancelling → 409**(单活 run 保护;error 状态视为可重启,起新 run 时清);UI 应 disable send 按钮直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 — 单活 run 形态下无歧义 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel 当前活跃 run;返 `{ok, task_id, run_status:"cancelling"}`;`run_status != "running"` → 409;LLM 同步 call 本身不可中断,最坏等当前一轮跑完 | 必填 |
| `GET /v1/tasks/{id}/files?path=` | 列子目录条目 + 面包屑 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 |
| `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下子目录条目 + 面包屑(user-rooted,不绑 task);dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件(user_root 下) | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | body `{path}`;文件或空目录;**path 是顶层目录(user_root 直接子项,且为目录)且仍被 task 引用 working_dir → 409**,先 DELETE 关联 task | 必填 |
| `POST /v1/files/rename` | body `{path, new_name}`;`new_name` 是新 leaf 名(校验同 task_name);sibling 已存在 → 409;**path 是顶层目录** → 同步 `UPDATE tasks.working_dir`(同事务 + FOR UPDATE 锁;有 running/cancelling task → 409;`check_no_subtask` 防嵌套 → 409);非顶层(子目录 / 文件)纯 FS rename | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
**SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 命中走 `cancelled{}` 后随 `done{}` 收流;异常路径走 `error{msg}`。30s 无 event 服务端发 `: ping` 注释心跳。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
@ -140,6 +141,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel,error 只能起新 run 顶掉);dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 |
| `[startup] reaped N stale active run(s)` | 上次 `main.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 |
| `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录(user_root 直接子项)被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完所有关联 task 再删目录。子目录不受此限,可直接删空目录 |
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先点 stop / `POST /v1/tasks/{id}/cancel` 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套(§7.4 no-subtask)。换一个不冲突的 new_name |
| `main.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |

View File

@ -1,7 +1,7 @@
"""Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE / no-subtask 校验。"""
from __future__ import annotations
from typing import Any, Optional
from typing import Any, Iterable, Optional
from uuid import UUID
from sqlalchemy import func, select, update
@ -106,6 +106,7 @@ def get_task(task_id: UUID) -> Optional[Task]:
def check_no_subtask(
working_dir: str,
user_id: UUID = SENTINEL_USER_ID,
exclude_task_ids: Optional[Iterable[UUID]] = None,
) -> None:
"""§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。
@ -116,6 +117,9 @@ def check_no_subtask(
`working_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用
`from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一
数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀
`exclude_task_ids` 用于 rename 场景:正在被一起改名的 task 是平移过去的,内部不冲突,
需要从比对集合里排掉,否则它们会和"自己未来的 working_dir"误判嵌套
"""
if not working_dir or not working_dir.strip():
return
@ -124,12 +128,15 @@ def check_no_subtask(
new_abs = from_db_path(working_dir).as_posix()
if not new_abs:
return
exclude = set(exclude_task_ids or ())
with session_scope() as s:
rows = s.execute(
select(Task.task_id, Task.working_dir)
.where(Task.user_id == user_id, Task.working_dir != "")
).all()
for existing_id, existing_dir in rows:
if existing_id in exclude:
continue
existing_abs = from_db_path(existing_dir).as_posix()
if not existing_abs or existing_abs == new_abs:
continue

View File

@ -0,0 +1,157 @@
"""Smoke: POST /v1/files/rename + 收紧的 POST /v1/files/delete。
跑法: .venv/Scripts/python.exe scripts/smoke_files_rename.py
依赖 .env PLATFORM_KEY / JWT_SECRET / ZCBOT_DB_URL
随机 user_id,run 完留 trace 自查;不清 DB(开发期约定)
"""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 读 .env(简单 KEY=VAL 解析)
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from fastapi.testclient import TestClient
from sqlalchemy import update
from core.storage import session_scope
from core.storage.models import Task
from web.app import create_app
def main() -> int:
app = create_app()
client = TestClient(app)
uid = uuid.uuid4()
plat_key = os.environ["PLATFORM_KEY"]
# login
r = client.post("/v1/auth/login", json={"user_id": str(uid), "platform_key": plat_key})
assert r.status_code == 200, r.text
token = r.json()["token"]
H = {"Authorization": f"Bearer {token}"}
ws = ROOT / "workspace" / "users" / str(uid)
def case(name: str, fn):
try:
fn()
print(f"[OK] {name}")
except AssertionError as e:
print(f"[FAIL] {name}: {e}")
raise
# case 1: 顶层目录 rename + DB UPDATE
def t1():
# 建 task 创出 working_dir
r = client.post("/v1/tasks", json={"name": "proj_a"}, headers=H)
assert r.status_code == 201, r.text
tid = r.json()["task_id"]
assert (ws / "proj_a").is_dir()
# rename
r = client.post("/v1/files/rename", json={"path": "proj_a", "new_name": "proj_a2"}, headers=H)
assert r.status_code == 200, r.text
body = r.json()
assert body["tasks_updated"] == 1, body
assert body["new"] == "proj_a2"
# FS 真的改了
assert not (ws / "proj_a").exists()
assert (ws / "proj_a2").is_dir()
# DB working_dir 跟着变
r = client.get(f"/v1/tasks/{tid}", headers=H)
assert r.status_code == 200
wd = r.json()["working_dir"]
assert wd.endswith("/proj_a2"), wd
case("顶层目录 rename → tasks_updated + FS + DB 同步", t1)
# case 2: 子级 rename 不动 DB
def t2():
sub = ws / "proj_a2" / "sub_old"
sub.mkdir(parents=True, exist_ok=True)
r = client.post(
"/v1/files/rename",
json={"path": "proj_a2/sub_old", "new_name": "sub_new"},
headers=H,
)
assert r.status_code == 200, r.text
assert r.json()["tasks_updated"] == 0
assert not sub.exists()
assert (ws / "proj_a2" / "sub_new").is_dir()
case("子级 rename → 纯 FS,tasks_updated=0", t2)
# case 3: rename 顶层时有 running task → 409
def t3():
# 拿当前 proj_a2 的 task,mock 标 running
r = client.get("/v1/tasks", headers=H)
rows = r.json()["results"]
tid = uuid.UUID(rows[0]["task_id"])
with session_scope() as s:
s.execute(update(Task).where(Task.task_id == tid).values(run_status="running"))
try:
r = client.post("/v1/files/rename", json={"path": "proj_a2", "new_name": "blocked"}, headers=H)
assert r.status_code == 409, r.text
assert "active run" in r.text
finally:
with session_scope() as s:
s.execute(update(Task).where(Task.task_id == tid).values(run_status="idle"))
case("顶层 rename 有 running task → 409", t3)
# case 4: 删顶层有 task 引用 → 409
def t4():
r = client.post("/v1/files/delete", json={"path": "proj_a2"}, headers=H)
assert r.status_code == 409, r.text
assert "task" in r.text and "引用" in r.text
case("delete 顶层 + 有 task 引用 → 409", t4)
# case 5: rename target sibling 已存在 → 409
def t5():
(ws / "occupied").mkdir(exist_ok=True)
r = client.post(
"/v1/files/rename",
json={"path": "proj_a2", "new_name": "occupied"},
headers=H,
)
assert r.status_code == 409, r.text
assert "already exists" in r.text
case("rename target sibling 存在 → 409", t5)
# case 6: 删空子目录(非顶层)→ 正常
def t6():
r = client.post("/v1/files/delete", json={"path": "proj_a2/sub_new"}, headers=H)
assert r.status_code == 200, r.text
assert not (ws / "proj_a2" / "sub_new").exists()
case("delete 子级空目录 → 200", t6)
# case 7: 新 user,顶层目录无 task 引用时可删
def t7():
uid2 = uuid.uuid4()
r = client.post("/v1/auth/login", json={"user_id": str(uid2), "platform_key": plat_key})
tok2 = r.json()["token"]
H2 = {"Authorization": f"Bearer {tok2}"}
# 手建顶层(模拟用户上传到不存在路径,API 会 mkdir)
ws2 = ROOT / "workspace" / "users" / str(uid2)
(ws2 / "orphan").mkdir(parents=True, exist_ok=True)
r = client.post("/v1/files/delete", json={"path": "orphan"}, headers=H2)
assert r.status_code == 200, r.text
assert not (ws2 / "orphan").exists()
case("delete 顶层目录无 task 引用 → 200", t7)
print("\n[ALL PASS]")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -257,6 +257,11 @@ class FileDeleteRequest(BaseModel):
path: str
class FileRenameRequest(BaseModel):
path: str # 被重命名的目录 / 文件,相对 user_root
new_name: str # 新的 leaf 名(不是路径),不含 / \ ..
class LoginRequest(BaseModel):
user_id: str
platform_key: str
@ -860,13 +865,37 @@ def create_app() -> FastAPI:
body: FileDeleteRequest,
user_id: UUID = Depends(require_user),
):
"""删 user_root 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。"""
"""删 user_root 下文件或**空**目录。非空目录 → 400;root → 400。
path **顶层目录**(user_root 直接子项,且为目录),还会查 tasks :
有任意 task working_dir 指向此目录 409,要求先 DELETE 关联 task
这是为了避免悬空引用(task.working_dir DB 字符串, FS 不会自动 unset)
"""
root = _load_user_root(user_id)
target = _safe_join(root, body.path)
if target.resolve() == root.resolve():
raise HTTPException(400, "cannot delete user_root")
if not target.exists():
raise HTTPException(404, f"path not found: {body.path}")
is_top_level_dir = (
target.is_dir() and target.parent.resolve() == root.resolve()
)
if is_top_level_dir:
db_form = to_db_path(target)
with session_scope() as s:
cnt = s.execute(
select(func.count()).select_from(Task).where(
Task.user_id == user_id, Task.working_dir == db_form,
)
).scalar_one() or 0
if cnt:
raise HTTPException(
409,
f"folder {body.path!r} 仍被 {cnt} 个 task 引用(working_dir);"
f"请先 DELETE 关联 task 再删目录",
)
try:
if target.is_dir():
target.rmdir() # 非空目录会触发 OSError
@ -876,6 +905,108 @@ def create_app() -> FastAPI:
raise HTTPException(400, f"delete failed: {e}")
return {"ok": True, "path": body.path}
@app.post("/v1/files/rename", tags=["files"])
def rename_path(
body: FileRenameRequest,
user_id: UUID = Depends(require_user),
):
"""重命名 user_root 下文件或目录(任意深度)。
- `path` 必填,指被重命名对象;不能为 user_root
- `new_name` 是新 leaf (`validate_task_name`:非空 / 不含 `/\\..` / dotfile / 255);
不是路径,parent 自动取自原 path
- 目标 sibling `<parent>/<new_name>` 不能已存在(防覆盖)
- **path 是顶层目录**(user_root 直接子项,且为目录) DB-aware:
* 同事务内 `SELECT ... FOR UPDATE` 锁该目录对应 task;任一 run_status
running/cancelling 409(避免 BG 线程握旧路径而 DB 已指新路径)
* `check_no_subtask(new_db, exclude=被改名 tids)` 防止改名后跟其它 task 形成嵌套
* `UPDATE tasks SET working_dir=new_db WHERE task_id IN (...)` 先写 DB
* `os.rename` FS;失败 抛错 session_scope 回滚 DB
* 唯一不一致窗口是 "FS 已改名 + commit 阶段失败"(PG 单事务 commit 极少失败)
- 非顶层(子目录 / 文件) FS rename,不动 DB
"""
from core.agent_builder import InvalidTaskName, validate_task_name
root = _load_user_root(user_id)
target = _safe_join(root, body.path)
if target.resolve() == root.resolve():
raise HTTPException(400, "cannot rename user_root")
if not target.exists():
raise HTTPException(404, f"path not found: {body.path}")
try:
new_name = validate_task_name(body.new_name)
except InvalidTaskName as e:
raise HTTPException(400, f"new_name 不合法: {e}")
if new_name == target.name:
raise HTTPException(400, f"new_name 与原名相同: {new_name!r}")
new_target = target.parent / new_name
if new_target.exists():
raise HTTPException(
409, f"target already exists: {_rel_to(root, new_target)!r}"
)
is_top_level_dir = (
target.is_dir() and target.parent.resolve() == root.resolve()
)
if not is_top_level_dir:
try:
target.rename(new_target)
except OSError as e:
raise HTTPException(400, f"rename failed: {e}")
return {
"ok": True,
"old": body.path,
"new": _rel_to(root, new_target),
"tasks_updated": 0,
}
# 顶层目录:DB-aware
old_db = to_db_path(target)
new_db = to_db_path(new_target)
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 HTTPException(
409,
f"folder has active run(s) on task(s) {active}; "
f"cancel before renaming",
)
try:
check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids)
except NoSubtaskError as e:
raise HTTPException(409, str(e))
if tids:
s.execute(
update(Task)
.where(Task.task_id.in_(tids))
.values(working_dir=new_db)
)
try:
target.rename(new_target)
except OSError as e:
# 抛 HTTPException 也会让 session_scope 走 except 分支回滚 UPDATE
raise HTTPException(400, f"FS rename failed: {e}")
return {
"ok": True,
"old": body.path,
"new": _rel_to(root, new_target),
"tasks_updated": len(tids),
}
# ───────────── Export ─────────────
@app.get("/v1/tasks/{task_id}/export", tags=["export"])

View File

@ -993,7 +993,8 @@ function renderFiles(data) {
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
<button class="small danger del-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="删(非空目录会失败)">×</button>
<button class="small mv-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="重命名">改名</button>
<button class="small danger del-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="删(非空目录 / 仍被 task 引用会失败)">×</button>
</div>
`;
}).join("");
@ -1008,20 +1009,55 @@ function renderFiles(data) {
$("file-list").querySelectorAll(".del-file").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); deleteFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); };
});
$("file-list").querySelectorAll(".mv-file").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); renameFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); };
});
}
async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return;
const tip = isDir
? "\n(非空目录会失败;若为顶层目录且仍被 task 引用,需先删 task)"
: "";
if (!confirm(`确认删除${what} "${name}"?` + tip)) return;
try {
await api("POST", "/v1/files/delete", { path: rel });
await loadFiles();
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
await loadFolderSuggestions();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message);
}
}
async function renameFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
const newName = prompt(`将${what} "${name}" 重命名为:`, name);
if (newName == null) return;
const trimmed = newName.trim();
if (!trimmed || trimmed === name) return;
try {
const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed });
// 面板若停在被改名的子树里,做前缀替换继续停留在等价位置
if (state.filesPath === rel) {
state.filesPath = res.new;
} else if (state.filesPath && state.filesPath.startsWith(rel + "/")) {
state.filesPath = res.new + state.filesPath.slice(rel.length);
}
await loadFolderSuggestions();
// 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷
if (res && res.tasks_updated > 0) {
await loadTaskList();
if (state.taskId) { await selectTask(state.taskId); return; }
}
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("重命名失败:" + e.message);
}
}
function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },