feat(tasks): 任务软删除(留对话轨迹做语料 + 可恢复)+ bump 0.17.0

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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-17 16:37:47 +08:00
parent 6d6e9f79b5
commit e87daa7c89
7 changed files with 97 additions and 30 deletions

View File

@ -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`(届时是新需求,不是技术债)。

View File

@ -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;截图留占位标注。突出对外优势(内部文献库、科研计算、可直接产出文件)。

7
RUN.md
View File

@ -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_<user_uuid>" /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 <dir>` 清;② 外部 `--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 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.16.2"
__version__ = "0.17.0"

View File

@ -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):

View File

@ -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")

View File

@ -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}")
if row.deleted_at is None:
s.execute(
_delete(Task).where(Task.task_id == tid, Task.user_id == user_id)
update(Task)
.where(Task.task_id == tid, Task.user_id == user_id)
.values(deleted_at=func.now())
)
remaining = s.execute(
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
@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: