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:
parent
094f4b0cd9
commit
7925dcef54
|
|
@ -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
9
RUN.md
|
|
@ -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` 重起 |
|
||||||
|
|
|
||||||
|
|
@ -229,8 +229,9 @@ 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 多 task 共享,exist_ok=True 不冲突。
|
||||||
working_dir_path.mkdir(parents=True, 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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
52
web/app.py
52
web/app.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue