diff --git a/DESIGN.md b/DESIGN.md index c7a5d74..06de639 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ - 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) - 任务持久化:任意时刻关机,下次能恢复 - 演化性:模型升级不需要大改架构 -- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邮箱密码登录(`users.email/password_hash`,bcrypt 校验),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) +- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG)/ web `/v1` API,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) --- @@ -25,31 +25,35 @@ zcbot/ ├── core/ │ ├── capabilities.py # ModelCapabilities,从 yaml 加载 │ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features -│ ├── loop.py # ReAct 主循环 +│ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel │ ├── probe.py # 真实探测对账 yaml 声称的能力 -│ ├── session.py # 消息列表 + meta + 落盘 -│ ├── skills.py # SkillRegistry (Anthropic 渐进披露) -│ └── task.py # TaskState +│ ├── session.py # 消息列表 + meta + 落 PG +│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) +│ ├── task.py # TaskState +│ ├── memory.py # per-user .memory/ 双层记忆 +│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path) +│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM +│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name ├── tools/ │ ├── base.py # Tool 基类 + _resolve │ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ ├── shell.py # subprocess + 黑名单 -│ ├── run_python.py # tmp .py + subprocess,过滤敏感 env +│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤 │ └── skill_tool.py # load_skill ├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets ├── prompts/system/general_v1.md -├── config/{agent.yaml, models/deepseek_v4.yaml} -├── workspace/ -│ └── users// -│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离) -│ └── / # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物 -├── core/agent_builder.py # 装配 lib: build_agent / system prompt / validate_task_name -└── main.py # 入口: web / db / probe 三子命令 +├── config/{agent.yaml, models/*.yaml} +├── workspace/users// +│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离 +│ └── / # 工作目录,用户起名(同 working_dir 多 task 共享) +├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html} +├── db/migrations/ # alembic +└── main.py # 入口:web / db / probe / user 四子命令 ``` -**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 `users` 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/` 换 `/`,布局不变。 +**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 users 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/` 换 `/`,布局不变。 -**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(dev SPA / 同事试用 — 邮箱密码,bcrypt 校验)或 `/v1/auth/login`(platform 机器对机器,platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities` → `LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 +**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(邮箱密码)或 `/v1/auth/login`(platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent` 读 `agent.yaml` → 加载 `ModelCapabilities` → `LLM(caps)` → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG。 --- @@ -58,9 +62,8 @@ zcbot/ ### 3.1 主循环(`core/loop.py`) ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。 - 工具结果对模型截 16K 字符,用户预览 400 字符 -- 后台 daemon 线程每 100ms 刷 spinner:`thinking... 1.3s ctx 12,345 tok` -- 每轮 LLM 返回追加 dim 一行 `[in N out N t Xs]` -- assistant 文本走 `rich.markdown.Markdown` 整段渲染(非流式) +- 事件通过 `sink.emit` 流式发布(§7 A,SSE 桥) +- `cancel_check: Optional[Callable[[], bool]]` 协作式 cancel,每轮 LLM 前 + tool_calls 之间 poll;命中给未执行 tool_call 补 `[cancelled by user]` 保 LiteLLM 协议 - `max_iterations` 从 capabilities 读 ### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`) @@ -72,8 +75,8 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。 ### 3.4 工具系统(Hybrid 范式) -**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill —— 离散操作。 -**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)—— 批处理 / 算数据 / 生成文档。 +**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。 +**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。 关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 ### 3.5 Skill 系统(Anthropic 渐进披露) @@ -90,13 +93,9 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / - `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录 - `skill` = 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐 `skills/` 注册表强校验) -**创建语义** —— working_dir 目录在 task 创建入口立即 `mkdir(parents=True, exist_ok=True)`(`name` 必填代表"显式声明项目";`working_dir` 留空 → fallback 用 name 作目录名)。`Task` 行在 web `/v1/tasks` POST 时即写;CLI 内仍走 `Session.append` 首条 user 消息触发的占位 INSERT(`ensure_local_task_row` idempotent,`name` 透传给 NOT NULL 列)—— REPL 启动后立刻 `/exit` 不留 DB 行(目录留着无害,跨 task 复用)。 +**创建语义** — working_dir 目录在 task 创建入口立即 `mkdir(parents=True, exist_ok=True)`(`name` 必填代表"显式声明项目";`working_dir` 留空 → fallback 用 name 作目录名)。`Task` 行在 web `POST /v1/tasks` 时即写。**Task 切换 / 软删 / 硬删** 走 dev SPA + `/v1/tasks*`(`DELETE /v1/tasks/{id}` 删 DB 行 + messages CASCADE;**FS 一律不动**,同 name 多 task 共享,绝不 rmtree)。原 CLI REPL(`chat / tasks / export`)2026-05-18 撤,详 §7.9。 -**Task 切换 / 软删** —— dev SPA 顶 bar 新建 modal + 左侧列表点切 / done / abandon 按钮 / 硬删按钮。无 user message 的 task DB 行可经 `DELETE /v1/tasks/{id}` 清掉(FS 一律不动 — 同 name 跨 task 共享,绝不 rmtree)。 - -**原子性** —— PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。 - -**入口**:`python main.py web` 起服务后,所有交互(新建 / resume / 改 status / 改 description / 改 skill / 改 name / 导出 docx / 看消息)走 web `/v1/*`(dev SPA 或 platform 端 / curl)。原 CLI REPL(`chat / tasks / export` 子命令)2026-05-18 撤,详 §7.9。 +**原子性** — PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。 ### 3.7 双层记忆(`core/memory.py`) @@ -107,17 +106,15 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / | Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) | | Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | -**system prompt 每次 build_agent 重建**,resume 也走 `_build_system_prompt` 并覆盖 `messages[0]` —— memory 演化即时生效。 +**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结**。 -memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 - -**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邮箱密码登录从 users 表直读、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 +**memory 永远在 FS,不入 DB**:本地 `workspace/users//.memory/`,SaaS `/users//.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 --- ## 4. 模型路由 -默认 `default_model: deepseek_v4.flash`。后续分模式路由思路: +默认 `default_model: deepseek_v4.flash`。分模式路由思路: | 模式 | 模型 | 理由 | |---|---|---| @@ -125,7 +122,7 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— | 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 | | fallback | claude_4_7.opus | V4 不行时手动切 | -成本量级(对比): +成本量级: | 任务 | flash | pro-max | Opus 4.7 | |---|---|---|---| @@ -144,13 +141,13 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤。 ### 七条具体原则 -1. Prompt 用 WHY+WHAT 不用 HOW —— 教"怎么思考"会降智强模型 +1. Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型 2. Skill 渐进披露,不写完整流程 -3. 工具按原子操作切分,不做高级封装 —— 留组合空间 +3. 工具按原子操作切分,不做高级封装 — 留组合空间 4. Model Profile 化,不硬编码 5. Capability Probing 对账实际行为 6. 版本化 Prompt(等真要切版本时再做) -7. ~~eval 评估~~ —— 已删,dogfooding 更有效 +7. ~~eval 评估~~ — 已删,dogfooding 更有效 ### 借鉴 | 来源 | 借鉴 | @@ -182,25 +179,22 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— ## 7. SaaS 化(草案,status=design,2026-05-12) > §1-§6 是**本地 dogfood 形态**;本节是**SaaS 形态**,把 core 包成多用户在线服务。 -> 不引入 platform/core 切分 —— core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在 CLI 入口 vs HTTP 入口。本节落地前 §1-§6 路线照走,不阻塞 dogfood。 +> 不引入 platform/core 切分 — core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在反代部署。本节落地前 §1-§6 路线照走,不阻塞 dogfood。 ### 7.0 与本地形态的兼容性 -SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本地形态(`python main.py web`)与 SaaS 形态走完全一致的代码路径,无 CLI / in-process 分叉(2026-05-18 撤,详 §7.9)。 - -**共享**:同一份 `core/` / `tools/` / SKILL.md / prompts / web `/v1` 路由 / dev SPA。 -**差别**: +SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。 | 维度 | 本地 | SaaS | |---|---|---| | 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI | | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | -| working_dir | `workspace/users///`(user_id 从 JWT 透传) | `/users///`(JWT `sub`) | -| Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.memory/`(仍是 FS,dotfile) | +| working_dir | `workspace/users///` | `/users///` | +| Memory | `workspace/users//.memory/` (FS, dotfile) | `/users//.memory/` | | Sandbox | subprocess + env 过滤 | per-task docker exec | | Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) | -`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录(`workspace/` vs `/`),不在 storage 形态。 +`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录,不在 storage 形态。 ### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 @@ -208,75 +202,74 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本 | 视图 | 入口语义 | 适用场景 | API | |---|---|---|---| -| **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&task_dir=` | -| **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` | +| **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&working_dir=` | +| **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` + `GET /v1/files` | 类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。 -- **Task** = DB 一行,一等公民,自带 `task_dir text` 字段: - - **新建必给 `name`**(简单名),`task_dir = workspace/users///`。同 name 多 task 共享 → "同一项目多对话"语义;不再支持空 task_dir / 自动 UUID 派生(原 ChatGPT thread 模式取消,纯对话也得起个项目名) - - **指定 → 项目化 task**,同 task_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体) -- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走 prefix cascade(§7.4) -- **No-subtask**:同 task_dir 允许(同项目多对话),前缀嵌套拒 +- **Task** = DB 一行,一等公民,自带 `working_dir` 字段: + - **新建必给 `name`**(简单名),`working_dir = workspace/users///`(留空 fallback 用 name)。同 working_dir 多 task 共享 → "同一项目多对话"语义 + - **指定 → 项目化 task**,同 working_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体) +- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走顶层目录 DB-aware 同事务 cascade(§7.4) +- **No-subtask**:同 working_dir 允许(同项目多对话),前缀嵌套拒 - **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload -- **Skill 产物**全落 task_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件 +- **Skill 产物**全落 working_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件 - **Skill 定义**是项目代码,跟部署走,所有用户共享 -**空 dir**(用户上传素材但还没开 task)在 dir tree 视图正常展示 —— 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。 +**空 dir**(用户上传素材但还没开 task)在 dir tree 视图正常展示 — 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。 -state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 task_dir 时由 §7.8 文件级悲观锁兜底。 +state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 working_dir 时由 §7.8 文件级悲观锁兜底。 ### 7.2 资源模型(/v1) -Task 一等公民(`/v1/tasks*`);files 与 task 正交(§7.1 双视图心智),走 user-rooted `/v1/files*`,以 `workspace/users//` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`。 +Task 一等公民;files 与 task 正交(§7.1),走 user-rooted `/v1/files*`,以 `workspace/users//` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI `/docs` Swagger UI 自查。 ``` Tasks - POST /v1/tasks 创建 {name(必填), working_dir?, description?, skill?}; - 留空 working_dir → 用 name 作目录名; - working_dir 派生 workspace/users///; - name/working_dir 不合法 → 400 + POST /v1/tasks {name(必填), working_dir?, description?, skill?};不合法 → 400 GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering= - 列表,返 `{page, page_size, count, results}` - 分页 1-based;page_size 1–100 clamp;status active/completed/abandoned; - skill 精确;working_dir 末段名(后端拼前缀比对);q 在 name+description ILIKE; - ordering DRF 风格逗号分隔,`-field` 倒序;allowlist - created_at/updated_at/name/status;**默认 `-created_at`** - GET /v1/tasks/{id} 单 task meta + 完整 messages - PATCH /v1/tasks/{id} {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI) - DELETE /v1/tasks/{id} 硬删:DB 行 + messages(CASCADE);**FS working_dir 保留** - (同 working_dir 多 task 共享,文件由用户经 /files/delete 单独清) - GET /v1/folders 列当前 user 的 working_dir(FS 是 source of truth + 关联 task 计数 + 最后使用时间) - GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector) - POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url} - **单活 run**(0004 简化):tasks.run_status in - ('running','cancelling') → 409;'error' 起新 run - 时清(跟 ok 一样视为可重启)。`SELECT … FOR UPDATE` - 锁 task 行,序列化并发 POST 防 `messages.idx` race - GET /v1/tasks/{id}/events SSE 流(见下)— 订阅 task 当前活动事件, - 单活 run 形态下无歧义,客户端只需 task_id - POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 `cancelling` + 信号 - broker;BG loop 在工具调用之间 poll 看见即退, - 给未执行 tool_call 补 `[cancelled by user]` - tool result(保 LiteLLM 协议),emit `cancelled` - 事件;finally 写终态 — 正常 / cancel 都回 `idle` - (不留持久标记),异常才写 `error`。 - run_status != `running` → 409。 - LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完 + 分页 1-based;page_size 1–100 clamp;ordering DRF 风格逗号分隔, + `-field` 倒序;allowlist created_at/updated_at/name/status; + **默认 `-created_at`**;返 `{page, page_size, count, results}` + GET /v1/tasks/{id} 单 task meta + PATCH /v1/tasks/{id} {status?,description?,name?,skill?};active 不让从 web 切回 + DELETE /v1/tasks/{id} 硬删:DB 行 + messages CASCADE;**FS working_dir 保留** + GET /v1/folders 列当前 user 的 working_dir + 关联 task 计数 + 最后使用时间 + GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector) + POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url} + **单活 run**(0004 简化):tasks.run_status in + ('running','cancelling') → 409;`SELECT … FOR UPDATE` + 锁 task 行序列化并发 POST 防 messages.idx race + GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件, + 单活 run 形态下无歧义,客户端只需 task_id + POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 cancelling + 信号 broker; + BG loop 在工具调用之间 poll 看见即退; + run_status != running → 409;LLM 同步 call 本身不可中断 -Files(user-rooted,不绑 task — `workspace/users//` 为根) - GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root; - dotfile(`.memory/` 等)一律隐藏(同 /v1/folders 约定) - POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename - GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 - POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒 +Auth + POST /v1/auth/login {user_id, platform_key} → JWT(platform 机器对机器) + POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用) + bcrypt 校验 users.password_hash(0005 加 UNIQUE(email)); + 错邮箱 / 错密码 / 未设密码统一 403 防探测 + +Files(user-rooted,workspace/users// 为根) + GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current}; + 留空 → user_root;dotfile(`.memory/` 等)一律隐藏 + POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename + GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 + POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒; + **path 是顶层目录(user_root 直接子项)且被 task 引用 → 409** + POST /v1/files/rename {path, new_name};sibling 已存在 → 409; + **path 是顶层目录** → 同事务 SELECT FOR UPDATE 锁关联 task + + 任一 running/cancelling → 409 + check_no_subtask 防嵌套; + DB UPDATE 在 FS rename 之前,FS 失败回滚 DB Export - GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp + GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp Misc - GET /healthz {"status":"ok"} - GET / 302 → /docs (Swagger UI 自查,本地形态便利) + GET /healthz {"status":"ok"} + GET / 302 → /static/dev.html(本地 dev SPA) ``` **SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: ` + `data: `): @@ -286,79 +279,72 @@ run_start {} llm_start {} text {"delta":""} tool_call {"name":"...","args":{...},"args_preview":"..."} -tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览给 UI +tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览 llm_end {"prompt_tokens":N,"completion_tokens":N} cancelled {} # cancel 命中,后随 done 收流 error {"msg":": "} done {} ``` -订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 —— messages 走 PG,未来要"刷新继续看流式"再加 event log。 +订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 — messages 走 PG,未来要"刷新继续看流式"再加 event log。 -**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。`/v1internal` 实验位(未启)。 - -**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧到 platform 域名 allowlist。 - -**Auth**:PLATFORM_KEY → JWT 兑换(过渡形态,见 §7.3);`Authorization: Bearer ` 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login`、`/static/*` 豁免。 +**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。 +**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧。 +**Auth**:Bearer JWT 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login*`、`/static/*` 豁免。 ### 7.3 认证 -**当前形态(D' 过渡,2026-05-15 落地)**:platform 服务端(或 dev 浏览器)持有 `PLATFORM_KEY` 共享密钥,调 `POST /v1/auth/login {user_id, platform_key}` → 后端校验 key 匹配 → 签 HS256 JWT(`sub=user_id`,默 7d TTL,`JWT_SECRET` env 签)→ 返 `{token, expires_at, user_id, ttl_seconds}`。后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。**信任模型**:platform 是单点可信中间层(持 KEY = 可为任意 user_id 签 token,等同 user 身份由 platform 注入);风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。 +**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): +- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入) +- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户 -**未来形态(真 OIDC,D 阶段后期)**:OIDC / Clerk / 自建邮箱登录,Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" —— **路由层 Depends 不动**,Bearer JWT 契约不变。所有 storage/executor scoped by `user_id`。**无 tenant 层** —— 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 +后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 + +**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。 + +**未来形态(真 OIDC)**:Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — **路由层 Depends 不动**,Bearer JWT 契约不变;邮箱密码路径同步下线。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 ### 7.4 存储:Postgres + 本地文件系统 ```sql -users(user_id uuid pk, email null unique, password_hash null, oidc_subject null, plan null, created_at) --- 0001 schema;0005 给 email 加 UNIQUE(NULL 不冲突,允许 platform_key 入口 user email=NULL 共存)。 --- 行入口三条:① main.py user add(发邮箱密码用户,bcrypt → password_hash;dev SPA 邮箱密码登录用) --- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id;email/password_hash 留 NULL) --- ③ 未来 OIDC(D' 真上线后,改写 login;email/oidc_subject 由 ID token 注入)。 --- 管理:撤用户 DELETE FROM users WHERE email=...(先清该 user 的 tasks,messages CASCADE); --- 改密 UPDATE users SET password_hash=;改邮箱 UPDATE users SET email=...(user_id 不动)。 +users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at) +-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存 +-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用) +-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id) +-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入) tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, status, model_profile, tokens_prompt, tokens_completion, cost_usd, - run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合并 runs 表) - run_error text null, -- error 状态的错误文本,其他状态 NULL + run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表) + run_error text null, created_at, updated_at); create index on tasks (user_id, working_dir); --- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串 --- (`workspace/users//`,name 是简单名,无 /\..); --- 新建强制 `name` 必填,空串只可能在 legacy 数据(开发期已 wipe)。 --- SaaS 阶段同理(基础是 /users//)。 --- 读写边界统一过 core/paths.py::{to_db_path,from_db_path}。 --- 入口校验 main.py::validate_task_name(): 拒空 / 含 /\NUL / `.` 起头 / >255。 +-- working_dir 存储约定:ROOT 内 → 相对 ROOT posix 串;ROOT 外 → 保留绝对 +-- 读写边界统一过 core/paths.py::{to_db_path, from_db_path} +-- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255 messages(message_id uuid pk, task_id fk, idx int not null, payload jsonb not null, tokens_in, tokens_out, created_at, unique (task_id, idx)); create index on messages using gin (payload jsonb_path_ops); --- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步) ``` -**0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs` 表 -角色等价于"task 当前 in-flight 状态",合并到 `tasks.run_status` + `tasks.run_error` 两列; -`usage_events` 是为未来计费预付的架构成本,真要计费再加,DB schema 改动便宜。`run_id` -取消 —— 单活 run 形态下它对客户端 / broker / cancel 全是冗余字段。 +**0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`usage_events` 是计费预付架构成本,真要计费再加。`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。 +**run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。 -**No-subtask 校验**(`create_task`):查同 user 下是否存在 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 task_dir 允许。**两侧先用 `from_db_path` 归一到 absolute posix 再比前缀**(混合存储形态 [相对+绝对] 不会漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 +**No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 -**Folder rename**(`old → new`,FS rename 成功后):`UPDATE tasks SET task_dir = new || substring(task_dir from len(old)+1) WHERE user_id=? AND (task_dir = old OR task_dir LIKE old||'/%')`。**用 `old/%` 而非 `old%`**,避免 `project_a` 误中 `project_a_other`。running task 引用时禁 rename / delete。 +**Folder rename / delete**(`/v1/files/rename` + `/v1/files/delete`):**files API 是目录树唯一 mutation 入口,DB-FS 一致性作服务端不变量内化**(§7.9 架构教训)。顶层目录(user_root 直接子项)走 DB-aware 分支:事务内 `SELECT ... FOR UPDATE` 锁关联 task + 任一 running/cancelling → 409 + `check_no_subtask(exclude=被改名 tids)` 防嵌套;rename UPDATE DB 在 FS rename 之前(FS 失败可回滚);delete 顶层目录有任意 task 引用 → 409 要求先 DELETE 关联 task。 -**Folder delete**:hard cascade,前端 modal 列影响面 + 输入 folder 名二确认。先 DELETE messages → DELETE tasks → FS 递归删;DB 成功 FS 失败由后台 GC 兜底清孤儿目录。`usage_events` 不参与 cascade。 - -**文件系统**(本地 `` = `workspace/`,SaaS 替换为部署根,布局不变): +**文件系统**(本地 `` = `workspace/`,SaaS 替换为部署根): ``` /users// .memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB - / # 项目目录,name 用户起(必填),task_dir 直接落这 - /... # 同 name 多 task 共享同目录(§7.1) + / # 项目目录,name 用户起(必填),working_dir 直接落这 + # 同 name 多 task 共享同目录(§7.1) ``` -本地优先 S3(部署简化 / 低延迟),storage 抽象层留好后续可换。 -**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration;CLI 启动校验 schema 版本,落后报错让用户跑 `cli db upgrade`(本地)或部署管线自动 `alembic upgrade head`(SaaS)。`cli migrate-from-fs --workspace ./workspace` 一次性导旧 JSON。 +**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。 ### 7.5 沙盒:Per-task 容器 + Per-run exec @@ -370,105 +356,82 @@ create index on messages using gin (payload jsonb_path_ops); | bind mount = user root | `/users//` → `/workspace`;同 user 多 task 不互隔(协作方便),跨 user 由独立实例隔离 | **资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。 - -**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。Executor Protocol 抽象后切换成本低。 +**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。 ### 7.6 Core 代码改造(按依赖顺序) -| # | 项 | 估时 | +| # | 项 | 状态 | |---|---|---| -| 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done | -| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 | -| 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `/users///`(本地 `` = `workspace/`,user_id 走 JWT);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 | -| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 | -| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 | -| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | -| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 | -| 8 | ~~**CLI 双模式**~~ —— **删**(2026-05-18):dev SPA 起后浏览器一直开着,CLI REPL `chat/tasks/export` 三命令已撤;`main.py` 入口只剩 `web / db / probe`,无双 transport(§7.9) | 已撤 | -| 9 | ~~Web UI 简洁版(Jinja2+HTMX)~~ → 改为 **API surface 完工**:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§7.9 取舍) | 已落 | +| 1 | 事件流化 `loop.py` | ✅ done | +| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) | ✅ done | +| 3 | working_dir 字段语义(name 必填,派生 `users///`,同 name 共享) | ✅ done | +| 4 | Files API(list/upload/download/delete/rename,user-rooted) | ✅ done | +| 5 | No-subtask 校验 | ✅ done | +| 6 | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 待 | +| 7 | HTTP /v1 surface | ✅ done | +| 8 | ~~CLI 双模式~~ | 撤(§7.9) | +| 9 | ~~Web UI~~ → API-only,UI 由 platform 实现 | 撤(§7.9) | -代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入,本仓库只维护 API)。 +代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入)。 ### 7.7 分阶段落地 -| 阶段 | 范围 | 工作量 | 验收 | -|---|---|---|---| -| A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 | -| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)| -| D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态走 JWT 跑通 | -| D' 过渡 | PLATFORM_KEY → JWT 兑换 + user_id 数据隔离 + dev SPA | done ✅ | `POST /v1/auth/login` 拿 token,`Authorization: Bearer` 走全部 /v1/tasks*;`web/static/dev.html` 单文件 3 栏 SPA 给开发自验。详 §7.3 | -| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 | -| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | -| ~~E~~ | ~~CLI transport 双模式~~ — **撤**(2026-05-18,§7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 整套删,`main.py` 入口只剩 web/db/probe | — | -| ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI | -| F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% | +| 阶段 | 范围 | 状态 | +|---|---|---| +| A | 事件流化 | ✅ | +| B | Storage 落 PG + working_dir 语义 + no-subtask | ✅ | +| D | HTTP /v1 surface | ✅ | +| D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | ✅ | +| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天,发布前必做 | +| C | Executor + sandbox | 3 天 | +| ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) | +| ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) | +| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 | -**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。 - -**D 落在 G 前面** —— 原排期 D 在 G 后(以为 dogfood 用 UI 跑),实际转向"platform 端联调"后,API surface 反而成阻塞;G 的 Jinja2+HTMX 投入(G1-G6 ~3 天)沉淀 = 删除前的 dogfood 价值,留下的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。 +**B 阶段一次性切换**:切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨,dogfood 即生效。 +**D 落在 G 前面**:原排期 D 在 G 后(以为 dogfood 用 UI 跑),转向"platform 端联调"后 API surface 反而成阻塞;G 的 Jinja2+HTMX 投入沉淀的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。 ### 7.8 已知风险 | 风险 | 缓解 | |---|---| -| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL(原 §7.6 #8 双模式)2026-05-18 整套撤,无双 transport 维护税 | -| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 | +| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL 整套撤无双 transport 维护税 | +| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation | | Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 | -| Running task 被 rename / delete | 后端校验 + UI 禁按钮 | +| Running task 被 rename / delete | 后端校验 + UI 禁按钮(详 §7.4) | | 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin | -| DB-then-FS 中断留孤儿目录 | 后台 GC 周期扫"FS 有但 DB 无引用" | +| DB-then-FS 中断留孤儿目录 | rename 顺序 DB UPDATE → FS rename(FS 失败回滚 DB);delete 后台 GC 周期扫"FS 有但 DB 无引用" | | 同 folder 多 task 并发写同名 | 文件级悲观锁,冲突早失败 | -| 同 task 并发 POST messages 撞 `messages.idx` UniqueConstraint | `POST /v1/tasks/{id}/messages` 单活 run 检查:`SELECT … FOR UPDATE` 锁 task 行 + 查 `tasks.run_status in ('running','cancelling')`,有 → 409;同事务标 running 避 TOCTOU。配启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error(进程 crash 残留)。未来真生产 multi-worker 换 heartbeat / lease | -| Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式 cancel:标 `cancelling` + broker 信号;`AgentLoop.cancel_check` 回调在每轮 LLM 前、tool_calls 之间 poll;命中给未执行 tool_call 补 `[cancelled by user]` tool result 保 LiteLLM 协议,emit `cancelled` 事件,BG finally `run_status` 回 `idle`(不留持久标记)。LLM 同步 call 本身不可中断 — 接受最坏等当前一轮跑完(几十秒内) | +| 同 task 并发 POST messages 撞 `messages.idx` | `POST /messages` 单活 run gate:`SELECT … FOR UPDATE` 锁 task + `run_status in ('running','cancelling')` → 409;启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error。未来 multi-worker 换 heartbeat / lease | +| Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式;LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完(几十秒) | | Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI | | 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 | ### 7.9 取舍说明 -**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth。rename 是 UI 主动动作,cascade 单事务搞定。 +**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth。rename 是 UI 主动动作,顶层目录走 DB-aware 同事务 cascade(§7.4)。 + +**`/v1/files/*` 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化**(2026-05-18):此前提过双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 `if path is top-level` 分支被视为"代码异味"。实际反了 — 这个分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。 **user auth 而非 tenant 层**:个人 SaaS 用不上。企业版加 `org_id` claim 等价。 -**skill 产物全落 cwd 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。 +**skill 产物全落 working_dir 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。 -**hard cascade 而非 soft orphan**:`orphaned` 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。(原 `usage_events` 表 0004 删 — 见下条) +**hard cascade 而非 soft orphan**:`orphaned` 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。 -**0004 删 `runs` + `usage_events` 表**(2026-05-18 决策): -- **`runs`**:实质是"task 当前 in-flight 状态"的影子表 —— `tokens_p/c` 写但从未被读(tokens 累计走 `tasks.tokens_prompt/_completion`),`started_at/finished_at/error` 也只写不读,`run_id` 唯二实用是 broker pub/sub 频道键 + cancel 参数。但 §7.1 选定单活 run 形态下 `run_id` 是冗余 —— 同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够。合并 `run_status` + `run_error` 两列入 `tasks`,删表;broker 改 task_id 索引;`/v1/tasks/{id}/runs/{rid}/{events,cancel}` 改 `/v1/tasks/{id}/{events,cancel}`。 -- **`usage_events`**:从未真写入(代码库零引用),纯死代码,为"未来计费"预付的架构成本。真要计费时改 DB schema 便宜,不预付。 -- **取舍代价**:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片)—— messages 表已记下每次对话产物,token 累计在 tasks,真要细粒度审计再补回 `usage_events`(届时是新需求,不是技术债)。 -- **`run_status` 终态语义**:`ok` / `cancelled` 收尾直接回 `idle`(用户视角"跑完了 / 停了"等价),只有 `error` 是持久终态(让用户能看到),起新 run 时由 `post_message` 清掉。 +**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`(届时是新需求,不是技术债)。 **本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。 -**API-only,UI 由 platform 实现**(2026-05-15 决策): -- **原计划**:Phase G 用 Jinja2 + HTMX 在本仓库做"简洁 Web UI",dogfood 用,真上线再做正经前端。已落地 G1-G6:task list / chat 流式 / files 浏览 / new / done/abandon/export/toast,共 ~600 行 HTML+CSS+SSE-HTML-片段。 -- **触发**:用户决定与已有 platform 联调,前端用 platform 的框架,本仓库再维护 HTML / CSS / HTMX 就是双套 UI 浪费。 -- **取舍**: - - 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖 - - SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠 - - 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`) - - auth 走 D' 过渡形态:邮箱密码(`users.email/password_hash` + bcrypt,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接 - - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 -- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连 +**API-only,UI 由 platform 实现**(2026-05-15):用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML/CSS/HTMX 就是双套 UI 浪费。删 `web/templates/*` + Jinja2/markdown-it-py/pygments 依赖,SSE event payload 从 HTML 片段切 JSON,路由统一 `/v1` 前缀。沉淀:sink 协议 / RunBroker fan-out / no-subtask / files 路径安全归一 / task_dir 相对存储全部保留,不被 UI 层牵连。 -**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used tab 持久化)→ `/v1/auth/login_password` 或 `/v1/auth/login` 拿 JWT → localStorage 存 → fetch+Bearer。 +**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15,05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),无构建链。与"UI 由 platform 实现"不冲突 — platform UI 是给真用户的生产形态;dev.html 是给开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具(SSE 调试在 curl 里看不到 UI 反应,Swagger 不发 SSE 流也没流式视图,删了再补不如留着)。登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化)→ JWT → localStorage → fetch+Bearer。 -**CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"): -- **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。 -- **触发**:dev SPA 落地后浏览器一直开着,REPL 命令(`/new /resume /done /abandon /desc /export`)与 web `/v1` 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。`--remote` 那套从未实现,也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖真用户路径)。 -- **取舍**: - - `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。 - - 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。 - - 净减 ~400 行 REPL 逻辑(`_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console` 等 CLI-only 代码),`main.py` ~180 行,`core/agent_builder.py` ~320 行。 -- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。 -- **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。 +**CLI REPL 撤,入口统一 `main.py {web,db,probe,user}`**(2026-05-18):原计划 `cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。dev SPA 落地后浏览器一直开着,REPL 命令与 web `/v1` 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。`--remote` 从未实现也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖)。**失**:CLI "无 auth 直跑调 core 内部状态"通道 — 但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可临时写几行 ad-hoc script,不需要常驻 CLI 命令。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 -**Web UI 走 server-render + HTMX 不上 SPA**:① 与 §5 "Less Scaffolding" 一致,不引入 React/Vue 构建链 / node_modules / 双语言双 lint;② chat 主交互是 SSE 流式追加 + 表单提交,HTMX `hx-swap` / `sse-swap` 原生覆盖,无需客户端状态管理;③ FastAPI 单进程既出 `/v1` JSON 也出 HTML 模板,部署单容器;④ 上限低(协作 / 实时多光标 / 复杂表单态做不动),真要做重前端再换栈,届时 `/v1` 已稳定可直接对接 SPA。 - -**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 —— DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 —— 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译。 +**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译。 --- diff --git a/PROGRESS.md b/PROGRESS.md index 81525c2..4fef75a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,8 +1,8 @@ # 实施进度 -> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 +> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — 复用 0001 schema 的 `users.email/password_hash`、加 UNIQUE(email)、加 `main.py user add` CLI、登录页两 tab 切换) +最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — `users.email/password_hash` + UNIQUE + `main.py user add` CLI + 登录页两 tab) --- @@ -15,61 +15,70 @@ | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | -| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**;**task-level cancel + dev SPA stop 按钮 ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);**入口归位 ✅**(`cli.py`→`main.py`,装配 lib 挪 `core/agent_builder.py`,CLI REPL 删,§7 E 撤);真 OIDC 待;C(Executor)待。 | +| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;**D `/v1` JSON API ✅**;**D' 过渡 auth(邮箱密码 + platform_key → JWT)+ dev SPA ✅**;**单活 run 锁 + cancel ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);**入口归位 ✅**(`cli.py`→`main.py`,装配 lib 挪 `core/agent_builder.py`);真 OIDC 待;C(Executor)待。 | --- ## 已完成关键能力 -- **05-19 / dev SPA 登录撤回 邮箱+密码,删 invites 表 + 邀请码路径**:接前两条 "邀请码 env → invites 表(0005)" 用了一天,用户复盘"还不如直接复用 users 表本来就有的 email/password_hash 列"。**判定**:invites + uuid5(NS, name) 推导是黑盒(同事不知道自己 user_id 从哪来、改 name 等于换身份),复用 users 列语义清晰、生产路径上也是 email+password 主流。dev 期不留兼容,**直接 downgrade 0005 → 删 invites 文件 → 写干净的新 0005 加 `UNIQUE(email)`**。**DB**(`db/migrations/.../20260519_1500_0005_users_email_unique.py`):`CREATE UNIQUE CONSTRAINT uq_users_email ON users(email)`;PG `UNIQUE` 对 NULL 不冲突,所以 platform_key 入口建的 user(email=NULL)不受影响。**密码哈希** `bcrypt>=4.1.0`(Windows wheel `bcrypt-5.0.0-cp39-abi3-win_amd64`),`bcrypt.hashpw` + `gensalt()` 默认 cost=12,存 ASCII str `$2b$12$...` 到 `users.password_hash`;`bcrypt.checkpw` 常数时间比对。**`web/auth.py`**:① 删 `resolve_invite` + `_INVITE_NAMESPACE` 常量 + `from core.storage.models import Invite`;② 新 `hash_password(s) -> str`(bcrypt hashpw → ASCII)+ `verify_password(s, h) -> bool`(checkpw,异常 fallback False 防 stored_hash 坏数据 500)+ `resolve_user_by_email(email, password) -> Optional[(user_id, email)]`(SELECT 不到时跑一次 dummy `bcrypt.checkpw(b"x", b"$2b$12$..."*53)` 防 timing oracle);③ `ensure_user_row` docstring 改写(只 platform_key 入口走;邮箱密码登录走 `main.py user add` 已经写好 users 行)。**`web/app.py`**:① `InviteLoginRequest` → `PasswordLoginRequest {email, password}`;② `/v1/auth/login_invite` 路由 → `/v1/auth/login_password`(命中返 `{token, expires_at, user_id, email, ttl_seconds}`;错邮箱 / 错密码 / 邮箱不存在统一 403 `invalid email or password` 不细分错因防探测用户存在性);③ 不调 `ensure_user_row`(行已由 `user add` 创);老 `/v1/auth/login` (platform 路径) 不动。**`core/storage/models.py`**:① 删 `Invite` class;② `email` 列加 `unique=True`;③ 文件 docstring 改"3 张表"(原 4)。**新加 CLI** `main.py user add --email X --password Y [--user-id UUID]`:bcrypt hash + INSERT users(email,password_hash[,user_id]);email lowercase + `@` 简单校验,password ≥ 6 字符;email UNIQUE 撞 / user_id PK 撞 → IntegrityError 走 except 退 2(报清错因)。`--user-id` 可选用于把已有 user_id(platform_key 入口创的孤儿)接到邮箱密码路径。**dev SPA `web/static/dev.html`**:登录卡 1 格("邀请码")改 **2 tab 切换**(`#tab-pw` "邮箱密码" 默认 / `#tab-key` "UUID + PLATFORM_KEY" 备用);CSS 新加 `.tabs / .tab-body` + active 态;JS `loginTab` + `LS_TAB` localStorage 持久化 last-used tab(回登录页保留);`switchLoginTab` auto-focus 第一个 input;`doLogin` 按 tab 分派 url + body — pw → `/v1/auth/login_password {email, password}`,key → `/v1/auth/login {user_id, platform_key}`;header 显示 name 时优先用返回的 `email`(pw 路径),`platform_key` 路径无显示名只显 uid 前 8 位。任意 input 上回车均触发登录。**Smoke**(curl + web 起服)4 case 全绿:① `user add dev@example.com / devpass123` 200 OK;② `POST /v1/auth/login_password` 正密码 200 + 返 user_id+email+JWT;③ 错密码 403 `invalid email or password`;④ 不存在邮箱 403 同;⑤ 重复 add 同 email → IntegrityError 走 except 退 2 + 报 `uq_users_email`;⑥ 老路径 `POST /v1/auth/login_invite` 404 Not Found(路由已删)。**没动**:JWT 签发 / `verify_token` / `require_user` Depends(签出来的 JWT 同款,后续 `/v1/*` 路由层完全无感)/ `/v1/auth/login` platform 路径 / DB users 表本身列结构(0001 早就有 email/password_hash,0005 只加约束)。**Migration 序号**:旧 0005(invites)在本次开发期 downgrade + 删 file 整体抹除,新 0005 重新使用同号承接 `users.email UNIQUE`(`down_revision: 0004`)。**文档同步**:RUN.md(env 段去 invites / 加 user CLI 段 + 路由表 `/v1/auth/login_invite` 改 `/v1/auth/login_password` + 故障兜底改邀请码相关 4 条 → 邮箱密码 4 条);PROGRESS 本条。**改文件**:`db/migrations/.../20260519_1500_0005_users_email_unique.py`(+33 行新文件,替代旧 0005 invites file)/ `db/migrations/.../20260519_1100_0005_invites_table.py`(删)/ `core/storage/models.py`(-15 行 Invite class,email 加 unique)/ `web/auth.py`(净 ~+10 行:删 ~40 行 invite,加 ~50 行 password helpers)/ `web/app.py`(login_invite → login_password 同尺寸)/ `web/static/dev.html`(+~80 行 JS/HTML/CSS 2 tab 登录)/ `main.py`(+~50 行 user CLI)/ `requirements.txt`(+1 行 bcrypt)/ `RUN.md` / `PROGRESS.md`。**净增量**:~+90 行(2 tab UI + CLI 都是有功能价值的代码,前一条邀请码净 -20 行被这条吃掉)。 -- **05-19 / 邀请码后端从 env 升级到 `invites` 表(0005 migration)** _(已撤,见上条)_:接上一条 `ZCBOT_INVITES` env 落地后,用户复盘"用一张表还是 env 哪个更好",讨论 trade-off 后判定:**≤5 人 + 一个人管 + 开发期**这个体量下 env 完胜 — 0 migration / 0 CRUD / 1 秒重启;但用户又转念:"既然将来真发布要表,不如现在就薄薄一张,我直接 DB 里 INSERT 试用,后期再加 CLI 命令"。**判定**:env 升表是低成本前置(schema 极薄,签名几乎不变),省去"未来再写 migration + 同步 env 数据"。**Schema**(0005,`db/migrations/.../20260519_1100_0005_invites_table.py`):`invites(token text pk, name text not null unique, created_at timestamptz default now())`。**设计取舍**:① **token PK** 而非自增 id — lookup 入口直接是 token,PK 自带索引,id 列纯死字段;② **name UNIQUE** — 同 name → 同 uuid5 → 两 token 共身份漏,DB schema 拦比应用层早一层;③ **不存 user_id** — `uuid5(固定 NS, name)` 推导,namespace 永远不动,存它等于冗余 + 偏离单一真相源(改 namespace 全员漂移这个风险一样存在,不靠存 user_id 解);④ **不存 revoked_at** — 撤销直接 `DELETE FROM invites WHERE name=...`,软删的"revoked 是否签 JWT"分支判断 ≤5 人场景用不上;⑤ 不加管理 CLI 子命令 — 用户明确说"我到时候在数据库里直接添加",后期需要再加 `python main.py invite {add|list|revoke}` 薄包装。**ORM**(`core/storage/models.py`)新加 `Invite` class(token PK / name unique / created_at server_default)。**`web/auth.py` 全面重写**:① 删 `_parse_invites` 函数(~40 行 env 字符串解析 + 校验)+ 删 `_INVITE_NAME_RE`(改交给 DB UNIQUE 约束)+ 删 `AuthConfig.invites` 字段 + 删 `AuthConfig.resolve_invite` 方法 + 删 `AuthConfig.from_env` 里读 `ZCBOT_INVITES` 那段 + 删 `AuthConfig.__init__` 多余 invites 参数;② 新增模块级 `resolve_invite(token) -> Optional[(name, user_id)]`:每次 login 走一次 `SELECT name FROM invites WHERE token = ?`,命中后 `uuid5(NS, name)` 算 user_id;**不缓存**,避免 `DELETE` 后还能登的不一致窗口(5 人级别用户开销可忽略,P99 也是 1ms 级);③ docstring 改写(`ZCBOT_INVITES` → `invites 表`,加发码 SQL 示例);④ `__all__` 加 `resolve_invite`(模块函数,不是 AuthConfig 方法,刻意分开 — DB 查询的 callable 不绑 cfg 闭包,更清晰)。**`web/app.py::login_invite` 路由**:从 `auth_cfg.invites` / `auth_cfg.resolve_invite` 改为 `from .auth import resolve_invite`;`if not auth_cfg.invites: 403 "invite login disabled"` 那条早返判断直接删 — 空表跟未配 env 是同一语义,`resolve_invite` 自然返 None;docstring 加发码 / 撤销 SQL 速查。**migration 跑通**:`db upgrade 0004 → 0005` 一把过,`db current` → `0005 (head)`。**Smoke 9 case 全绿**(TestClient 起 app + 直 DB SQL):① `AuthConfig` 不再有 invites/resolve_invite 字段;② `auth.py` 源码 grep 完全无 `ZCBOT_INVITES` / `_parse_invites` 痕迹(env 即使设了也不被读);③ 空表 → resolve_invite 任何 token 返 None;④ 手动 `INSERT INTO invites(token, name) VALUES('smoke_tok_alice','alice')`;⑤ `resolve_invite('smoke_tok_alice')` 命中 → `('alice', uuid5(NS,'alice'))`;⑥ `POST /v1/auth/login_invite {token: 'smoke_tok_alice'}` 200 + 正确 user_id + name;⑦ 错 / 空 token → 403 `invalid invite token`;⑧ JWT 调 `/v1/tasks` 200 + 老 `/v1/auth/login` (platform 路径) 仍工作;⑨ `DELETE FROM invites WHERE name='alice'` 后立刻 login 403(验证无缓存);⑩ 同 name 两行 INSERT 触发 `IntegrityError`(UNIQUE 生效);⑪ 同 token 两行 INSERT 触发 `IntegrityError`(PK 生效)。**没动**:`POST /v1/auth/login`(platform 机器对机器入口,与 invite 路径正交)、JWT 签发逻辑(`mint_token` / `verify_token`)、dev SPA(前端只调 `/v1/auth/login_invite` 路由 contract 没变)、SENTINEL 撤的所有清理(本次之前一步,见下条)。**文档同步**:RUN.md `.env` 示例段去 `ZCBOT_INVITES` 注释 + 加"邀请码不走 env 改 invites 表"指向 + Auth env 段重写邀请码段(指向表 + 发码 SQL + 撤销 SQL) + 日常命令段重写发码流程(`INSERT INTO invites(...)` + 不用重启 web,每次 login 都查 DB) + 路由表 `/v1/auth/login_invite` 说明改 "invites 表 token 未命中(含空表)→ 403" + 故障兜底重写四行 invite 相关 (含 INSERT 冲突时 DB 约束的诊断 + name 修改 = 换身份的语义清晰化) + DESIGN §7.4 schema 段加 invites 表声明 + 元描述;**未动 PROGRESS 老 env 条目**(历史记录,演进过程合理);新加本条作为升级里程碑。**改文件**:`db/migrations/.../20260519_1100_0005_invites_table.py`(+38 行新文件)/ `core/storage/models.py`(+15 行 Invite class + docstring)/ `web/auth.py`(净 -50 行,~30 行加 resolve_invite 函数 + docstring 重写,~80 行删 env 解析)/ `web/app.py`(净 -7 行 login_invite 路由简化)/ `RUN.md`(env / 邀请码 / 命令 / 路由 / 故障兜底五段)/ `DESIGN.md`(§7.4 schema +5 行)/ `PROGRESS.md`(+ 本条)。**净增量**:~-20 行(尽管加了一张表 + ORM,删 env 解析的逻辑更省)。**后期 follow-up**(用户提的):`python main.py invite {add|list|revoke}` 薄包装 —— 真要写时大概就是 3 条 click 子命令分别包 INSERT/SELECT/DELETE,留给真需要管理面的时候做。 -- **05-19 / SENTINEL user 彻底撤(数据 + 代码)**:接邀请码 login 落地后,`SENTINEL_USER_ID = UUID('00000000-...')` 这个本地 CLI 时代的兜底 user 已彻底无角色 —— CLI 早撤,web 必从 JWT 拿 user_id,`agent_builder.py:190` 的 `uid = user_id or SENTINEL_USER_ID` fallback 永远不触发(web 永远显式传)。按 CLAUDE.md "不写兼容层" 心智一次连根拔。**DB 数据**:`DELETE FROM users WHERE user_id=SENTINEL` CASCADE 删 5 个 dev 期 task + 307 条 messages(经用户确认不保留);`rm -rf workspace/users/00000000.../`(14 个 smoke 残留目录 + 1 个真实任务,用户确认不留)。**代码 8 处**:① `core/storage/models.py` 删 `SENTINEL_USER_ID` 常量 + 文件 docstring 改;② `core/storage/engine.py` 删 `ensure_local_sentinel` 函数 + `User`/`SENTINEL_USER_ID` import + 文件 docstring 改(改写 "users 行由 web auth 入口按需 INSERT");③ `core/storage/__init__.py` 删两个导出 + docstring 改;④ `core/storage/utils.py` 三个函数 `ensure_local_task_row / upsert_task / check_no_subtask` 的 `user_id` 默认参数(原 `= SENTINEL_USER_ID`)全改必填 + 删 import;⑤ `core/agent_builder.py` `user_id: Optional[UUID] = None` → `user_id: UUID`(KEYWORD_ONLY,前面加 `*,`),删 `uid = user_id or SENTINEL_USER_ID` 改 `uid = user_id`,删 `ensure_local_sentinel()` 调用,删 import,docstring 改两处 — 一处文件头 (移除 "默认填 SENTINEL"),一处 build_agent 函数注释 ("None → SENTINEL" 改 "必填,web 入口从 JWT 拿");两个 `TaskState(...)` 构造点都加 `user_id=uid`;⑥ `core/task.py::TaskState` dataclass 加 `user_id: UUID` 字段(放 task_id 后,无默认值 = 必填),`save()` 透传给 `upsert_task(user_id=self.user_id, ...)`,`from_row()` 加 `user_id=row.user_id`;⑦ `core/session.py::Session.append` 删多余 `ensure_local_task_row` 调用块(8 行)+ 改 docstring(写明 "前置条件:tasks 行已由 web 入口写入") — Session 不持有 user_id,这调用本来就是历史 CLI 残留(REPL 时 task 可能尚未 INSERT),现在 web 入口 100% 先 INSERT,这条 ensure 是 ON CONFLICT DO NOTHING no-op,删了反而清爽;⑧ `core/memory.py` docstring 改 ("本地 CLI = SENTINEL user" → "user_id 由 web auth 入口 JWT sub 透传");⑨ `web/auth.py` 删 `SENTINEL_USER_ID` import + `__all__` 条目 + `ensure_user_row` docstring 提及 SENTINEL 改写;⑩ `db/migrations/.../0001_initial_schema.py:12` 注释改 (`ensure_local_sentinel` → `web.auth.ensure_user_row`)。**关键判断**:`build_agent` 参数顺序问题 — `user_id` 必填放在一堆带默认参数后面会 `SyntaxError: non-default argument follows default`,改 `*, user_id: UUID, ...` keyword-only;唯一 caller `web/app.py:199` 已 keyword 调用,无 break。**Smoke 13 case 全绿**:① 全模块 import 不炸;② TaskState 新字段;③ utils.py 三函数签名 `inspect.Parameter.empty` 必填;④ build_agent KEYWORD_ONLY 必填;⑤ DB 无 SENTINEL 行;⑥ `/v1/auth/login_invite` 200 + uuid5 推导;⑦ POST /v1/tasks 201(走 `ensure_local_task_row(user_id=user_id)`);⑧ GET /v1/tasks count=1;⑨ PATCH 走 update_task 200;⑩ GET /v1/files + /v1/folders 200;⑪ DELETE 204;⑫ user 行存在并清理;⑬ `python main.py db current` 子进程跑通 + 输出 `0004 (head)`。**没动**:DB schema(0004 已最终态,SENTINEL 是数据非 schema)、`users` 表本身、`/v1/auth/login`(platform 机器对机器入口仍需要)、邀请码登录路径。**文档同步**:① RUN.md 关键路径段 Workspace 行去 "本地 CLI sentinel = 00000000..." 表述;② DESIGN.md 三处 — §1 working_dir 路径解释(去 "dev SPA 默认填 SENTINEL")+ §7.0 共享差别表(working_dir / Memory / Auth 三行去 sentinel 措辞,Auth 行加邀请码 + platform_key 双路径)+ §7.9 取舍 (去 "dev SPA 默认填 SENTINEL");③ DESIGN.md §3.7 memory 段去 "本地 CLI 走 SENTINEL"。**改文件**:`core/{storage/models.py,storage/engine.py,storage/__init__.py,storage/utils.py,agent_builder.py,task.py,session.py,memory.py}` + `web/auth.py` + `db/migrations/.../0001_initial_schema.py` + `RUN.md` + `DESIGN.md`(共 12 个文件,净 ~-50 行)。**bonus 价值**:把"操作 user 数据的函数必须显式传 user_id"作为编译期约束(Python 必填参数)固化下来 — 以后再加多 user 相关函数 / caller 时,IDE / typechecker 会直接拦到。 -- **05-19 / dev SPA 邀请码登录(`ZCBOT_INVITES` env + `POST /v1/auth/login_invite`)**:用户要把 dev SPA 发给 ≤5 个同事试用,原登录页要求填 `user_id (UUID)` + `platform_key` 两件事 — 同事记不住 UUID、`platform_key` 也不该暴露(那是 platform 与 zcbot 间的机器对机器共享密钥,泄漏 = 任意伪造 user_id)。**方案**:加一条"邀请码"login 路径与原 `/v1/auth/login` 共存(签同款 JWT)。**后端** `web/auth.py`:新增 `ZCBOT_INVITES` env 解析(`name:token,name:token,...` 格式;name 限 `[A-Za-z0-9_-]{1,40}` / token 非空 / 不含 `,:` / ≤200;name 唯一、token 唯一 —— 重复 token 表两人同身份,默认拒);user_id 由 `uuid5(_INVITE_NAMESPACE, name)` 推导(固定 UUID namespace `9b5e7a2a-...`,**不能改**,改了所有邀请用户身份全漂移),重启稳定、纯纯无状态;`AuthConfig.invites` 是 `{token: (name, uuid)}` dict,启动时一次解析完丢内存。**新路由** `POST /v1/auth/login_invite {token}` → 同款 JWT + `{name, user_id}` 返给前端展示用;`ZCBOT_INVITES` 未配 → 整个入口 403 `invite login disabled`(避免裸跑暴露);token 未命中 → 403 `invalid invite token`。**老路由 `/v1/auth/login` 不动** — platform 服务端机器对机器入口语义不一样(它能注入指定 user_id,邀请码只能登 env 里配过的人),OIDC 替换时也只动这条,留着合理。**前端** `web/static/dev.html`:登录卡 2 格(uuid + key)→ 1 格"邀请码"输入(type=password 避免肩窥),去 SENTINEL 预填、去 `LS_UID` 自动填充;header 显示 `name · uuid前8位`(name 缺失老 token 升级前回落到 uuid full,`title` 攻略悬停看完整 uuid);加 `LS_NAME` localStorage 持久化;logout 清三件 LS。**给同事发码流程**(RUN.md 写了):`python -c "import secrets;print(secrets.token_urlsafe(16))"` 生 token,`.env` 加 `ZCBOT_INVITES=alice:tok1,bob:tok2,...`,重启 `main.py web`,把 URL + 各自 token 发给同事。**撤销某人**:从 env 里删那条;**真要换身份**:改 name(他之前的 task 在另一个 user 下访问不到)。**Smoke 14 case 全绿**:① 单元 `_parse_invites` 8 case(未配 / 正常 / uuid5 重启稳定 / 未命中或空 token / 非法 name 拒 / 重复 name 拒 / 重复 token 拒 / 缺 colon 拒 / token 含 colon 拒 / 多余分隔符空白容忍);② 路由 `TestClient` 6 case(命中 alice / 命中 bob 不同 uid / 错 token 403 / 空 token 403 / JWT 调 `/v1/tasks` 200 / 无 Authorization 401 / 老 `/v1/auth/login` 仍工作 / `ZCBOT_INVITES` 未配 invite 路径 403)。**dev.html UI**:`grep` 旧字段 `SENTINEL / li-uid / li-key / platform_key` 全清。**没动**:`require_user` Depends(签 JWT 是同款,后续路由层完全无感)/ `ensure_user_row`(两路径都调,幂等 INSERT)/ DB schema(用户行靠登录时按需建);DESIGN(纯过渡 auth 形态扩展,§7 D' 仍写"PLATFORM_KEY → JWT" 的本意 — invite 是同一条路的小扩展)。**文档**:RUN.md 同步(env 段加 `ZCBOT_INVITES` 说明 + 给同事发码流程 + 路由表加 `/v1/auth/login_invite` + 故障兜底 4 行 invite 相关条目)+ PROGRESS 本条。**改动文件**:`web/auth.py`(+~70 行 `_parse_invites` + `AuthConfig.invites` + `resolve_invite`)/`web/app.py`(+~25 行 `InviteLoginRequest` + 新路由)/`web/static/dev.html`(净 +~15 行 -~20 行,登录块精简 + LS_NAME)/`RUN.md`(env 段 / 路由表 / 故障兜底)。 -- **05-19 / dev SPA 任务/文件 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧**:用户提"左侧任务行加下拉菜单(删除/完成/废弃/导出 docx,不同颜色)、右侧文件同理、文件顶栏长项目名压'文件'换行不要、聊天框加跟文件 panel 一样的上传按钮;另:上传后右侧刷新、工具调用返回时右侧也刷新"。**做法**:① **单例浮层菜单**(`#floating-menu`,`position: fixed`)避开 pane `overflow:auto` 裁剪 — `showMenu(triggerEl, items)` 算 trigger 右下展开,空间不足翻上;点 trigger 外 / resize / 任何 scroll 关菜单。② **任务行**(`renderTaskList`):右侧加 `⋯` trigger,菜单 4 项 `complete/abandon/export/delete`,颜色 `act-complete #2e7d32` / `act-abandon #c77800` / `act-export #1565c0` / `act-delete var(--accent)`;`complete/abandon` 在非 active 任务上 disabled,`export` 在 0 消息时 disabled;点击 trigger `stopPropagation` 不触发 row 选中;`state.tasksById` 缓存避免 menu 里再查。③ **文件行**(`renderFiles` + `fileMenuItems`):删除原内联 `改名 / ×` 两个按钮,统一改 `⋯` 菜单 — `重命名` / `下载`(目录不出现) / `删除`,同套颜色;`state.entriesByRel` 缓存 entry。④ **中间 pane-head 已有的 完成/废弃/导出/删除 4 个按钮保留**(操作当前打开任务还是顺手),重构 `setTaskStatus(tid, status, name)` / `deleteTask(tid, name, nMsg)` / `exportTask(tid)` 接受 tid 参数,中间按钮与左侧菜单共用同一组函数。⑤ **"文件"二字换行**:`.pane-head .label` 加 `white-space: nowrap; flex-shrink: 0`;同时 `#files-proj` 改 `flex: 0 1 auto` + `min-width: 0` + ellipsis + JS 端 `projName.slice(0, 11) + "…"` 截短(完整名留 title) — 双保险防长项目名挤爆顶栏。⑥ **聊天框上传**(`#chat-upload`):与右侧 `#btn-upload` 都触发同一 ``,`uploadSelected` 不变(上传到 `state.filesPath` 当前右侧目录),末尾 `await loadFiles()` 已有刷新。⑦ **工具调用刷新文件**:`handleSseEvent` 的 `tool_result` 分支加 `scheduleFilesRefresh()`,debounce 500ms 避免每次 tool_result 都 hit `/v1/files`(SSE 一轮回复里 tool_call 经常一连串)。**没动**:后端(纯前端 UX 调整);DESIGN(不动 — 非架构);RUN(不动 — 无 CLI / env / 文件布局变化);中间 pane 已有按钮文案与 disabled 规则保持不变。**文档**:只动 PROGRESS(按 CLAUDE.md 三文档边界)。**改文件**:仅 `web/static/dev.html`(+~110 行 JS/CSS,-~10 行旧内联按钮代码)。 -- **05-19 / dev SPA `/v1/files/download` 加 `Cache-Control: no-cache` + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%**:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(`┌─┐│`)+ 裸 ```...``` 围栏,从未用 mermaid + `![]()`,matplotlib 生成的 `figures/fig*.png` 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch `/v1/files/download` 命中浏览器**启发式缓存**(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。**修法**:① `web/app.py::download_file` 加 `headers={"Cache-Control": "no-cache"}` —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② `skills/proposal/scripts/quality_check.py::check_figures` 新加(共 4 条):**1) `figures/` 有 png 但 sections 0 个 `![](...)` 引用 → 图全没挂上**,2) 任何 fenced 代码块里出现 box-drawing 字符(`┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶`)→ ASCII 字符画当图,3) mermaid 块必须有首行 `%% caption: <题>`,4) 同 task 内 mermaid caption 不能撞名;③ **hash → caption 命名重构**(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):`render_diagrams.py` 删 `mermaid_hash()` + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 `caption_to_stem()` 清洗(保留 CJK/字母/数字,其它折 `_`,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);`render_docx.py` 删 `mermaid_hash()` + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ **SKILL.md 精简**(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(`quality_check` 会拦)")。**为什么这一波改这么散**:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。**端到端 smoke**(`/tmp/zcbot_repro` 临时 task):mermaid 块 `%% caption: 总体架构` → `figures/fig_总体架构.png` 落盘 → docx `figures: 1` 报告对、`word/media/image1.png` 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:`figures/ 有 N 张 png 0 个 ![]()` / `[md:L] ASCII 字符画 ┌─┐│└─┘` / `[md:L] mermaid 缺首行 %% caption` / `mermaid caption 撞名 X 出现在 md1:L1, md2:L2`。**没动**:`render_docx.py` 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(`fig1_xxx.png` 风格留着,反正不冲突,`figures/` 同时存在 mermaid 的 `fig_.png` 与 matplotlib 的 `fig_.png` 两种风格)/ `templates/*.md` 里 mermaid 示例首行 `%% caption:` 本来就有(只是历史可选,现在强制约定到位)。**hash → caption 兼容性**:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 `fig_.png` 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。**文档**:**只动 PROGRESS + skills/proposal/SKILL.md**(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 `Cache-Control` 改动是 `/v1/files/download` 行为微调,客户端无感、文档化为后续 follow-up 可选)。 -- **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `` blob URL;② pdf → `