From e87daa7c8916951d7564dc6bc346a693b7d8efa1 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 17 Jun 2026 16:37:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E4=BB=BB=E5=8A=A1=E8=BD=AF?= =?UTF-8?q?=E5=88=A0=E9=99=A4(=E7=95=99=E5=AF=B9=E8=AF=9D=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E5=81=9A=E8=AF=AD=E6=96=99=20+=20=E5=8F=AF=E6=81=A2?= =?UTF-8?q?=E5=A4=8D)+=20bump=200.17.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /v1/tasks/{id} 从硬删(DELETE + CASCADE + rmdir)改为软删(置 deleted_at), messages/usage_events 及工作目录文件全部保留,留作训练语料且可恢复;新增 POST /v1/tasks/{id}/restore;list_tasks/list_folders 计数过滤 deleted_at IS NULL; delete_file 顶层目录 409 引用检查排除软删 task(避免"任务删了文件夹却删不掉")。 0010 migration 加 tasks.deleted_at(additive 可空,存量行自动视为未删)。 推翻 DESIGN 原 hard-cascade 决策;文件归档方案(restic 备份 + DB 事件日志)写入 DESIGN 待办。 Co-Authored-By: Claude Opus 4.8 (1M context) --- DESIGN.md | 4 +- PROGRESS.md | 6 ++ RUN.md | 7 +- core/__init__.py | 2 +- core/storage/models.py | 5 ++ .../20260617_1000_0010_task_soft_delete.py | 34 +++++++++ web/app.py | 69 ++++++++++++------- 7 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 db/migrations/versions/20260617_1000_0010_task_soft_delete.py diff --git a/DESIGN.md b/DESIGN.md index 02587c5..d5009d1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -498,7 +498,9 @@ create index on usage_events (model_profile, created_at); **skill 产物全落 working_dir 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。 -**hard cascade 而非 soft orphan**:`orphaned` 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。 +**~~hard cascade 而非 soft orphan~~ → task 改软删除(2026-06-17 推翻)**:原决策为避免 `orphaned` 特殊 case 选硬删(`DELETE tasks` CASCADE 连带 messages/usage_events)。公测后目标变为**沉淀用户对话轨迹做训练/研究语料**,硬删 = 语料永久丢失,故推翻:`DELETE /v1/tasks/{id}` 改为置 `tasks.deleted_at`(0010 migration),从 `list_tasks` / `list_folders` 计数中过滤,messages/usage_events(CASCADE 不再触发)与工作目录文件全部保留;新增 `POST /v1/tasks/{id}/restore` 恢复。原"特殊 case"成本被一处 `WHERE deleted_at IS NULL` 收口(列表是唯一用户可见入口,按 id 取单 task 的端点不过滤,恢复/直链仍可达)。心智改为:**平台对数据 append-only,用户"删除" = 可见性状态,永不销毁字节**。物理清理留给将来的管理员工具。`delete_file` 顶层目录 409 引用检查同步排除软删 task(否则"任务都删了文件夹却删不掉"死结)。 + +**文件留存(归档)—— 设计已定,实现待办**(2026-06-17):任务对话靠软删除即留在 DB;但**用户文件在 FS 上,删除/覆盖即字节丢失**,需单独留存以供训练/研究。已对齐的方案(尚未实现,优先级靠后):**① 基础设施层定时增量备份做持久化地基**(restic/borg → 只进不删、内容寻址去重,定时跑;与应用代码完全解耦 → 新端点/新工具自动覆盖不会漏,且捕获删除+覆盖+最终成品,这是"删除前归档"钩子拿不到的)+ **② 应用层轻量事件日志**(删除/覆盖时只追加 user/task/path/time/reason 一条,补 ① 缺的用户意图/出处语义;放 DB 表 `data_events` 而非 jsonl,避并发追加竞争)。**起步同盘**(防误删+留语料够;不防整盘损坏 —— 已知边界,将来换备份 target 到第二块盘/异地即可,纯配置改动)。**不选**"每个删除端点内联 copytree-再删":横切关注点手写 N 处 → 易漏(删文件/夹/skill、rename/upload/i2i 覆盖入口持续增加)、只看得见删除一瞬、跨卷拷脆。覆盖(如 seedream i2i 改图)若 ① 颗粒度不够,将来在该具体工具内定点补"覆盖前快照",不铺全局钩子。 **0004 删 `runs` + `usage_events` 表**(2026-05-18):`runs` 表 tokens_p/c 写但从未读(真 tokens 走 tasks 累计),`started_at/finished_at/error` 也只写不读;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余。合并 `run_status` + `run_error` 两列入 `tasks`。`usage_events` 从未真写,纯死代码,真要计费再加。**代价**:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片) — messages 表已记下产物,token 累计在 tasks,真要细粒度审计再补回 `usage_events`(届时是新需求,不是技术债)。 diff --git a/PROGRESS.md b/PROGRESS.md index af62c35..99ab6be 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,12 @@ ## 已完成关键能力 +### 2026-06-17 / 任务软删除(留对话轨迹做语料 + 可恢复) + +- 背景:公测后目标转为沉淀用户对话/文件做训练研究语料;原"hard cascade"硬删任务会连带 messages/usage_events 永久丢失,推翻该决策(DESIGN §取舍同步标注)。 +- 改动:`tasks` 加 `deleted_at` 列(0010 migration,additive 可空);`DELETE /v1/tasks/{id}` 从 `DELETE` 改为置 `deleted_at=now()`,不再触发 CASCADE、不动工作目录文件(原 rmdir 清理一并去掉);`list_tasks` / `list_folders` 计数加 `WHERE deleted_at IS NULL` 过滤;新增 `POST /v1/tasks/{id}/restore` 恢复;`delete_file` 顶层目录 409 引用检查排除软删 task。 +- 文件留存(归档)方案已在 DESIGN 记录(restic 备份地基 + DB 事件日志 + 起步同盘),**实现待办**,优先级靠后。bump 0.16.2 → 0.17.0。 + ### 2026-06-17 / 用户操作说明书(详 + 精简两版)+ 文献库库容 21W→100W 全量更新 - 新增 `docs/操作说明书.md`(详版)+ `docs/操作说明书-精简版.md`:面向科研用户、不出现产品代号、从登录后正式操作讲起。覆盖三栏布局、**个人文件夹 → 工作目录 → 任务**三层概念(任务≠文件夹、多任务可共享一个工作目录)、新建任务、对话、技能矩阵(含 paper)、文件管理、进阶(方案确认卡/消息目录/记忆)、任务管理、图像视频、账户存储、FAQ;截图留占位标注。突出对外优势(内部文献库、科研计算、可直接产出文件)。 diff --git a/RUN.md b/RUN.md index 40a4bc3..baf158e 100644 --- a/RUN.md +++ b/RUN.md @@ -65,7 +65,7 @@ python -m venv .venv # 3) DB schema 上车 .venv/Scripts/python.exe main.py db upgrade head -.venv/Scripts/python.exe main.py db current # 应输出 0007 (head) +.venv/Scripts/python.exe main.py db current # 应输出 0010 (head) ``` --- @@ -160,7 +160,8 @@ 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/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | -| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 | +| `DELETE /v1/tasks/{id}` | **软删**(204):置 `deleted_at`,从列表隐藏;messages/usage_events 及工作目录文件全部保留(留作语料 + 可恢复),不动任何磁盘文件;已软删幂等 204 | 必填 | +| `POST /v1/tasks/{id}/restore` | 恢复软删的 task(置 `deleted_at=NULL`),重新出现在列表;返回 task meta;未软删幂等成功;跨 user / 不存在 → 404 | 必填 | | `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 | | `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 | | `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 | @@ -683,7 +684,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt | Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) | | `db upgrade` 报 `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 | | Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 | -| `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf ` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir | +| task 删了文件还在 | 现在 `DELETE /v1/tasks/{id}` 是**软删**,本就不动任何磁盘文件(留作语料 + 可恢复);要清磁盘走 `POST /v1/files/delete`。彻底物理删 task(及 messages)留给将来的管理员清理工具;当前如需手动:`psql> DELETE FROM tasks WHERE task_id=...`(messages/usage_events CASCADE) | | Sandbox 容器内 `touch /workspace/x` 报 `Permission denied` | 容器 uid 1000 与 host `zcbot` 用户 uid 不一致(bind mount 保 host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 | | Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` | | 启动报 `ZCBOT_SANDBOX_BACKEND=docker but sandbox init failed: ...` | docker daemon 没起 / 用户不在 docker group / network create 失败。先跑 `main.py sandbox check` 看哪一项 err | diff --git a/core/__init__.py b/core/__init__.py index dbd17b8..2402258 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.16.2" +__version__ = "0.17.0" diff --git a/core/storage/models.py b/core/storage/models.py index 199cba0..485f7e1 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -82,6 +82,11 @@ class Task(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) + # 软删除标记(0010):置时间即"逻辑删除",从列表隐藏但 DB 行 / messages / usage_events / + # 工作目录文件全部保留(留作语料 + 可恢复)。NULL = 未删。物理删只在管理员清理时走。 + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) class Message(Base): diff --git a/db/migrations/versions/20260617_1000_0010_task_soft_delete.py b/db/migrations/versions/20260617_1000_0010_task_soft_delete.py new file mode 100644 index 0000000..04da13a --- /dev/null +++ b/db/migrations/versions/20260617_1000_0010_task_soft_delete.py @@ -0,0 +1,34 @@ +"""tasks.deleted_at 列(任务软删除). + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-06-17 + +给 tasks 加 deleted_at 列(可空,默认 NULL=未删)。DELETE /v1/tasks/{id} 从硬删 +改为软删(置 deleted_at=now()),列表查询过滤 deleted_at IS NULL;新增 +POST /v1/tasks/{id}/restore 恢复。软删后 messages / usage_events(原 CASCADE 不再触发) +及工作目录文件全部保留,留作训练语料并支持恢复。 + +只加列、不动现有数据;历史行 deleted_at 默认 NULL,自动视为"未删"。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0010" +down_revision: Union[str, None] = "0009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "deleted_at") diff --git a/web/app.py b/web/app.py index 35eebb1..afaf5bb 100644 --- a/web/app.py +++ b/web/app.py @@ -1110,8 +1110,8 @@ def create_app() -> FastAPI: s.strip() for s in (run_status or "").split(",") if s.strip() in rs_allowed } or None - # 组装 WHERE - conditions = [Task.user_id == user_id] + # 组装 WHERE(软删除的 task 永不出现在列表;恢复见 /restore) + conditions = [Task.user_id == user_id, Task.deleted_at.is_(None)] if status: conditions.append(Task.status == status) if skill: @@ -1207,7 +1207,11 @@ def create_app() -> FastAPI: db_form = f"workspace/users/{user_id}/{name}" stat = s.execute( select(func.count(), func.max(Task.updated_at)) - .where(Task.user_id == user_id, Task.working_dir == db_form) + .where( + Task.user_id == user_id, + Task.working_dir == db_form, + Task.deleted_at.is_(None), + ) ).first() n = int((stat[0] if stat else 0) or 0) lu = stat[1] if stat else None @@ -1327,41 +1331,55 @@ def create_app() -> FastAPI: @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) def delete_task(task_id: str, user_id: UUID = Depends(require_user)): - """硬删除:DELETE DB 行(messages / usage_events CASCADE)。 + """软删除:置 deleted_at=now(),从任务列表隐藏。 - 若 working_dir 已无任何 task 引用且 FS 目录为空 → best-effort rmdir - 清孤儿(非空 / 不存在 / 没权限 都静默跳过 —— working_dir 视为可重生视图)。 - 外部 --working-dir(DB 串绝对)不动,只清 ROOT 内相对路径。跨 user → 404。 + DB 行 / messages / usage_events(原 CASCADE 不再触发)及工作目录文件全部保留 + —— 留作训练语料,且可经 POST /v1/tasks/{id}/restore 恢复。不动任何磁盘文件。 + 已软删的再次调用幂等返回 204。跨 user / 不存在 → 404。 """ try: tid = UUID(task_id) except ValueError: raise HTTPException(404, f"invalid task id: {task_id!r}") - from sqlalchemy import delete as _delete - from core.paths import from_db_path with session_scope() as s: - wd_db = s.execute( - select(Task.working_dir).where( + row = s.execute( + select(Task.deleted_at).where( Task.task_id == tid, Task.user_id == user_id, ) - ).scalar_one_or_none() - if wd_db is None: + ).first() + if row is None: raise HTTPException(404, f"task not found: {tid}") - s.execute( - _delete(Task).where(Task.task_id == tid, Task.user_id == user_id) - ) - remaining = s.execute( - select(func.count()).select_from(Task).where( - Task.user_id == user_id, Task.working_dir == wd_db, + if row.deleted_at is None: + s.execute( + update(Task) + .where(Task.task_id == tid, Task.user_id == user_id) + .values(deleted_at=func.now()) ) - ).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 + @app.post("/v1/tasks/{task_id}/restore", tags=["tasks"]) + def restore_task(task_id: str, user_id: UUID = Depends(require_user)): + """恢复软删除的 task(置 deleted_at=NULL),重新出现在列表。 + + 未软删的幂等成功。跨 user / 不存在 → 404。 + """ + try: + tid = UUID(task_id) + except ValueError: + raise HTTPException(404, f"invalid task id: {task_id!r}") + with session_scope() as s: + row = s.execute( + select(Task).where(Task.task_id == tid, Task.user_id == user_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(404, f"task not found: {tid}") + row.deleted_at = None # ORM 脏标记,session_scope 提交时落库 + n = s.execute( + select(func.count()).select_from(Message).where(Message.task_id == tid) + ).scalar_one() + usage = _usage_aggregates(s, [tid]) + return _task_dict(row, n_messages=n, usage=usage.get(tid)) + @app.patch("/v1/tasks/{task_id}", tags=["tasks"]) def patch_task( task_id: str, @@ -2062,6 +2080,7 @@ def create_app() -> FastAPI: select(func.count()).select_from(Task).where( Task.user_id == user_id, Task.working_dir == db_form, + Task.deleted_at.is_(None), # 软删 task 不再算引用 ) ).scalar_one() or 0 if n: