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 / 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 / `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 中文。 - **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` | 必填 | | `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 形态下无歧义 | 必填 | | `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 本身不可中断,最坏等当前一轮跑完 | 必填 | | `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/files?path=` | 列 user_root 下子目录条目 + 面包屑(user-rooted,不绑 task);dotfile 隐藏 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件(user_root 下) | 必填 |
| `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 | | `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 | 必填 | | `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` 默认起效)。 **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 自动忽略不报错 | | `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 按钮 | | 点 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 级 | | `[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` 重起 | | `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 `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 | | `/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 校验。""" """Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE / no-subtask 校验。"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Iterable, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
@ -106,6 +106,7 @@ def get_task(task_id: UUID) -> Optional[Task]:
def check_no_subtask( def check_no_subtask(
working_dir: str, working_dir: str,
user_id: UUID = SENTINEL_USER_ID, user_id: UUID = SENTINEL_USER_ID,
exclude_task_ids: Optional[Iterable[UUID]] = None,
) -> None: ) -> None:
"""§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。 """§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。
@ -116,6 +117,9 @@ def check_no_subtask(
`working_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用 `working_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用
`from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一 `from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一
数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀 数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀
`exclude_task_ids` 用于 rename 场景:正在被一起改名的 task 是平移过去的,内部不冲突,
需要从比对集合里排掉,否则它们会和"自己未来的 working_dir"误判嵌套
""" """
if not working_dir or not working_dir.strip(): if not working_dir or not working_dir.strip():
return return
@ -124,12 +128,15 @@ def check_no_subtask(
new_abs = from_db_path(working_dir).as_posix() new_abs = from_db_path(working_dir).as_posix()
if not new_abs: if not new_abs:
return return
exclude = set(exclude_task_ids or ())
with session_scope() as s: with session_scope() as s:
rows = s.execute( rows = s.execute(
select(Task.task_id, Task.working_dir) select(Task.task_id, Task.working_dir)
.where(Task.user_id == user_id, Task.working_dir != "") .where(Task.user_id == user_id, Task.working_dir != "")
).all() ).all()
for existing_id, existing_dir in rows: for existing_id, existing_dir in rows:
if existing_id in exclude:
continue
existing_abs = from_db_path(existing_dir).as_posix() existing_abs = from_db_path(existing_dir).as_posix()
if not existing_abs or existing_abs == new_abs: if not existing_abs or existing_abs == new_abs:
continue 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 path: str
class FileRenameRequest(BaseModel):
path: str # 被重命名的目录 / 文件,相对 user_root
new_name: str # 新的 leaf 名(不是路径),不含 / \ ..
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
user_id: str user_id: str
platform_key: str platform_key: str
@ -860,13 +865,37 @@ def create_app() -> FastAPI:
body: FileDeleteRequest, body: FileDeleteRequest,
user_id: UUID = Depends(require_user), 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) root = _load_user_root(user_id)
target = _safe_join(root, body.path) target = _safe_join(root, body.path)
if target.resolve() == root.resolve(): if target.resolve() == root.resolve():
raise HTTPException(400, "cannot delete user_root") raise HTTPException(400, "cannot delete user_root")
if not target.exists(): if not target.exists():
raise HTTPException(404, f"path not found: {body.path}") 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: try:
if target.is_dir(): if target.is_dir():
target.rmdir() # 非空目录会触发 OSError target.rmdir() # 非空目录会触发 OSError
@ -876,6 +905,108 @@ def create_app() -> FastAPI:
raise HTTPException(400, f"delete failed: {e}") raise HTTPException(400, f"delete failed: {e}")
return {"ok": True, "path": body.path} 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 ───────────── # ───────────── Export ─────────────
@app.get("/v1/tasks/{task_id}/export", tags=["export"]) @app.get("/v1/tasks/{task_id}/export", tags=["export"])

View File

@ -993,7 +993,8 @@ function renderFiles(data) {
${escapeHtml(e.name)} ${escapeHtml(e.name)}
</span> </span>
<span class="size">${humanSize(e.size)}</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> </div>
`; `;
}).join(""); }).join("");
@ -1008,20 +1009,55 @@ function renderFiles(data) {
$("file-list").querySelectorAll(".del-file").forEach((btn) => { $("file-list").querySelectorAll(".del-file").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); deleteFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); }; 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) { async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件"; const what = isDir ? "目录" : "文件";
if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return; const tip = isDir
? "\n(非空目录会失败;若为顶层目录且仍被 task 引用,需先删 task)"
: "";
if (!confirm(`确认删除${what} "${name}"?` + tip)) return;
try { try {
await api("POST", "/v1/files/delete", { path: rel }); await api("POST", "/v1/files/delete", { path: rel });
await loadFiles(); await loadFiles();
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
await loadFolderSuggestions();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message); 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) { function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), { fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },