files: working_dir 视为可重生 FS 视图(DELETE task 顺手清空孤儿 + delete_file 去 task-ref 闸)

DB 是 source of truth,FS working_dir 可独立删 / 用户手删 / 跨机器迁移丢失,
下次 build_agent 自动 mkdir 重建。三处改:

- core/agent_builder.py: working_dir.mkdir(exist_ok=True) 从 if not resume:
  里挪出,resume 也兜底建目录
- web/app.py DELETE /v1/tasks/{id}: 删完后若同 user 无其他 task 引用 +
  FS 空 + ROOT 内相对路径 → best-effort rmdir 清孤儿;外部 --working-dir
  (DB 绝对串)静默跳过
- web/app.py POST /v1/files/delete: 顶层目录去掉"有 task 引用 → 409"闸,
  允许独立删空目录,task.working_dir 字段不动

smoke case 4 改 200 + working_dir 不变;新增 case 8(空目录自动清)/
case 9(非空保留),全 9 pass。PROGRESS / RUN 跟着更。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 08:10:36 +08:00
parent 094f4b0cd9
commit 7925dcef54
5 changed files with 74 additions and 40 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`
最后更新:2026-05-19(0006 模型切换 + usage_events v2 表:task 级模型 PATCH / `GET /v1/models` / 前端顶栏下拉 + 历史小标 / chat usage 落 messages 双写 + usage_events 一行,A 粒度下条 send 生效) 最后更新:2026-05-20(working_dir 视为可重生 FS 视图:DELETE task 顺手清空孤儿目录;POST /v1/files/delete 顶层目录去掉 task 引用 409 闸;build_agent resume 也兜底 mkdir)
--- ---
@ -21,6 +21,10 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-05-20
- **working_dir 视为可重生 FS 视图**:DB 是 source of truth,FS 目录可独立删 / 用户手动 rmtree / 跨机器迁移丢失,**下次跑就自动 mkdir 重建**。三处改:① `DELETE /v1/tasks/{id}` 删完后若同 user 下再无 task 引用此 working_dir 且 FS 目录为空 → best-effort `rmdir` 清孤儿(非空 / 不存在 / 外部 --working-dir 静默跳过)。② `POST /v1/files/delete` 顶层目录去掉「有 task 引用就 409」闸,允许独立删空目录,task.working_dir 字段不动。③ `core/agent_builder.py::build_agent``working_dir_path.mkdir(parents=True, exist_ok=True)``if not resume:` 里挪出,resume 也兜底建目录(用户手删 FS 后再 send message 不会炸)。smoke `scripts/smoke_files_rename.py` 增 case 4 (200 + working_dir 不变) / case 8 (DELETE task 空目录自动清) / case 9 (非空目录保留),全 9 pass。**没动**:DB schema、rename 顶层目录的同步 UPDATE 逻辑(rename 是明确改名,和"删后重生"语义不同)、外部 --working-dir(DB 绝对串)的清理(避免误删用户外部项目)。
### 2026-05-19 ### 2026-05-19
- **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 从死字段变 source-of-truth,顶栏下拉 → `PATCH /v1/tasks/{id}` 即换,**A 粒度下条 send 生效**(当前 run 不受影响;running 中切 UI 提示"跑完后生效")。`build_agent` resume 时优先 task.model_profile,新建 task POST body 加可选 `model_profile`(留空 → `cfg["default_model"]`)。`GET /v1/models` 扫 `config/models/*.yaml` 列可选项(含 display_name / thinking_mode / is_default),`ModelCapabilities` 加 `display_name` 字段,deepseek_v4.yaml 两 variant 各填名。**前端**:chat-meta 加下拉(切了 PATCH+提示)、新建对话框 modal 加 `<select id="nt-model">`、message 历史按 `messages.model_profile` 切换点画小标(`── DeepSeek V4 Pro ──`,连续同 model 不重复)。**统计 schema**:0004 删掉的简陋 usage_events 字段不够多态,本次重建 v2 形态(`event_id/user_id/task_id/message_id/kind/model_profile/units jsonb/cost_usd`),chat 已接入(`core/storage/usage.py::record_chat_usage`,`loop.py` 在 assistant message 入库后调,litellm cost map 算钱),媒体扩展位(image/video/audio kind)预留不动 schema。**双写**:同时回填 `messages.tokens_in/out/model_profile`,查 message 详情时不需 JOIN。**索引**:`(user_id, created_at)` / `(task_id)` / `(model_profile, created_at)`,用户级配额 query JOIN-free。**没动**:CLI / RUN.md(无 env / 命令变化)、`tasks.tokens_prompt/completion/cost_usd` 保留作 task 级粗概览。 - **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 从死字段变 source-of-truth,顶栏下拉 → `PATCH /v1/tasks/{id}` 即换,**A 粒度下条 send 生效**(当前 run 不受影响;running 中切 UI 提示"跑完后生效")。`build_agent` resume 时优先 task.model_profile,新建 task POST body 加可选 `model_profile`(留空 → `cfg["default_model"]`)。`GET /v1/models` 扫 `config/models/*.yaml` 列可选项(含 display_name / thinking_mode / is_default),`ModelCapabilities` 加 `display_name` 字段,deepseek_v4.yaml 两 variant 各填名。**前端**:chat-meta 加下拉(切了 PATCH+提示)、新建对话框 modal 加 `<select id="nt-model">`、message 历史按 `messages.model_profile` 切换点画小标(`── DeepSeek V4 Pro ──`,连续同 model 不重复)。**统计 schema**:0004 删掉的简陋 usage_events 字段不够多态,本次重建 v2 形态(`event_id/user_id/task_id/message_id/kind/model_profile/units jsonb/cost_usd`),chat 已接入(`core/storage/usage.py::record_chat_usage`,`loop.py` 在 assistant message 入库后调,litellm cost map 算钱),媒体扩展位(image/video/audio kind)预留不动 schema。**双写**:同时回填 `messages.tokens_in/out/model_profile`,查 message 详情时不需 JOIN。**索引**:`(user_id, created_at)` / `(task_id)` / `(model_profile, created_at)`,用户级配额 query JOIN-free。**没动**:CLI / RUN.md(无 env / 命令变化)、`tasks.tokens_prompt/completion/cost_usd` 保留作 task 级粗概览。

9
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-19(dev SPA 登录改 邮箱+密码;`POST /v1/auth/login_password`;`main.py user add` CLI;Ubuntu systemd 部署 + 无感更新指引) 最后更新:2026-05-20(working_dir 改为可重生 FS 视图:`DELETE /v1/tasks/{id}` 顺手清空孤儿目录;`POST /v1/files/delete` 顶层目录无 task-ref 闸)
--- ---
@ -117,7 +117,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 | | `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 | | `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);UI 应 disable send 直到 SSE `done` | 必填 | | `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);UI 应 disable send 直到 SSE `done` | 必填 |
@ -126,7 +126,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path}`;文件或空目录;**path 顶层目录且被 task 引用 → 409**,先 DELETE 关联 task | 必填 | | `POST /v1/files/delete` | `{path}`;文件或空目录;顶层目录(working_dir 视图)可独立删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 | | `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 | | `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
@ -240,7 +240,7 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干
| Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) | | Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| `db upgrade``column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 | | `db upgrade``column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 | | Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
| `--working-dir` 指定后 task 删了目录还在 | 设计如此 — 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf <dir>` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) | | `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY``curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` | | `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY``curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
@ -252,7 +252,6 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` | | `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` | | `systemctl restart zcbot` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` |
| `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完关联 task 再删目录。子目录不受此限 |
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename | | `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name | | `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
| 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写 `.env` 重起 | | 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写 `.env` 重起 |

View File

@ -229,9 +229,10 @@ def build_agent(
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade) # (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
if not resume: if not resume:
check_no_subtask(str(working_dir_path), user_id=uid) check_no_subtask(str(working_dir_path), user_id=uid)
# 新建 task 立刻建工作目录 —— 用户已声明项目,目录就该存在 # working_dir 立刻建出 —— DB 是 source of truth,FS 目录视为可重生的视图。
# (同 working_dir 多 task 共享,exist_ok=True 不冲突) # resume 时也兜底 mkdir(用户可能经 /v1/files/delete 删过空目录),
working_dir_path.mkdir(parents=True, exist_ok=True) # 同 working_dir 多 task 共享,exist_ok=True 不冲突。
working_dir_path.mkdir(parents=True, exist_ok=True)
tool_base = Path(tool_base) if tool_base else Path.cwd() tool_base = Path(tool_base) if tool_base else Path.cwd()

View File

@ -109,12 +109,20 @@ def main() -> int:
s.execute(update(Task).where(Task.task_id == tid).values(run_status="idle")) s.execute(update(Task).where(Task.task_id == tid).values(run_status="idle"))
case("顶层 rename 有 running task → 409", t3) case("顶层 rename 有 running task → 409", t3)
# case 4: 删顶层有 task 引用 → 409 # case 4: 删顶层(有 task 引用)→ 200,task.working_dir 字段不动(可重生)
def t4(): def t4():
r = client.post("/v1/files/delete", json={"path": "proj_a2"}, headers=H) r = client.post("/v1/tasks", json={"name": "to_be_deleted"}, headers=H)
assert r.status_code == 409, r.text assert r.status_code == 201, r.text
assert "task" in r.text and "引用" in r.text tid = r.json()["task_id"]
case("delete 顶层 + 有 task 引用 → 409", t4) wd_before = r.json()["working_dir"]
assert (ws / "to_be_deleted").is_dir()
r = client.post("/v1/files/delete", json={"path": "to_be_deleted"}, headers=H)
assert r.status_code == 200, r.text
assert not (ws / "to_be_deleted").exists()
r2 = client.get(f"/v1/tasks/{tid}", headers=H)
assert r2.status_code == 200
assert r2.json()["working_dir"] == wd_before, r2.json()
case("delete 顶层 + 有 task 引用 → 200,task.working_dir 不变", t4)
# case 5: rename target sibling 已存在 → 409 # case 5: rename target sibling 已存在 → 409
def t5(): def t5():
@ -149,6 +157,28 @@ def main() -> int:
assert not (ws2 / "orphan").exists() assert not (ws2 / "orphan").exists()
case("delete 顶层目录无 task 引用 → 200", t7) case("delete 顶层目录无 task 引用 → 200", t7)
# case 8: DELETE /v1/tasks 时若 working_dir 空且无其他引用 → 顺手 rmdir
def t8():
r = client.post("/v1/tasks", json={"name": "auto_clean"}, headers=H)
assert r.status_code == 201, r.text
tid = r.json()["task_id"]
assert (ws / "auto_clean").is_dir()
r = client.delete(f"/v1/tasks/{tid}", headers=H)
assert r.status_code == 204, r.text
assert not (ws / "auto_clean").exists()
case("DELETE task 空 working_dir 顺手清", t8)
# case 9: DELETE /v1/tasks 时 working_dir 非空 → 目录保留(best-effort)
def t9():
r = client.post("/v1/tasks", json={"name": "keep_dir"}, headers=H)
assert r.status_code == 201, r.text
tid = r.json()["task_id"]
(ws / "keep_dir" / "user_file.txt").write_text("hello", encoding="utf-8")
r = client.delete(f"/v1/tasks/{tid}", headers=H)
assert r.status_code == 204, r.text
assert (ws / "keep_dir" / "user_file.txt").is_file()
case("DELETE task 非空 working_dir 保留", t9)
print("\n[ALL PASS]") print("\n[ALL PASS]")
return 0 return 0

View File

@ -650,20 +650,39 @@ def create_app() -> FastAPI:
@app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)): def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""硬删除:DELETE DB 行(messages / runs CASCADE)。**FS task_dir 不动** """硬删除:DELETE DB 行(messages / usage_events CASCADE)。
( name task 共享,文件由用户经 /files/delete 单独清) user 404
working_dir 已无任何 task 引用且 FS 目录为空 best-effort rmdir
清孤儿(非空 / 不存在 / 没权限 都静默跳过 working_dir 视为可重生视图)
外部 --working-dir(DB 串绝对)不动,只清 ROOT 内相对路径 user 404
""" """
try: try:
tid = UUID(task_id) tid = UUID(task_id)
except ValueError: except ValueError:
raise HTTPException(404, f"invalid task id: {task_id!r}") raise HTTPException(404, f"invalid task id: {task_id!r}")
from sqlalchemy import delete as _delete from sqlalchemy import delete as _delete
from core.paths import from_db_path
with session_scope() as s: with session_scope() as s:
result = s.execute( wd_db = s.execute(
select(Task.working_dir).where(
Task.task_id == tid, Task.user_id == user_id,
)
).scalar_one_or_none()
if wd_db is None:
raise HTTPException(404, f"task not found: {tid}")
s.execute(
_delete(Task).where(Task.task_id == tid, Task.user_id == user_id) _delete(Task).where(Task.task_id == tid, Task.user_id == user_id)
) )
if result.rowcount == 0: remaining = s.execute(
raise HTTPException(404, f"task not found: {tid}") select(func.count()).select_from(Task).where(
Task.user_id == user_id, Task.working_dir == wd_db,
)
).scalar_one() or 0
if wd_db and not remaining and not Path(wd_db).is_absolute():
try:
from_db_path(wd_db).rmdir()
except OSError:
pass
return None # 204 return None # 204
@app.patch("/v1/tasks/{task_id}", tags=["tasks"]) @app.patch("/v1/tasks/{task_id}", tags=["tasks"])
@ -973,9 +992,8 @@ def create_app() -> FastAPI:
): ):
"""删 user_root 下文件或**空**目录。非空目录 → 400;root → 400。 """删 user_root 下文件或**空**目录。非空目录 → 400;root → 400。
path **顶层目录**(user_root 直接子项,且为目录),还会查 tasks : 顶层目录(working_dir 视图)可独立删 task.working_dir 字段不动,
有任意 task working_dir 指向此目录 409,要求先 DELETE 关联 task 下次 build_agent / 写文件入口按需 mkdir 重建,FS 目录视为可重生
这是为了避免悬空引用(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)
@ -984,24 +1002,6 @@ def create_app() -> FastAPI:
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