doc: 精简 DESIGN/PROGRESS/RUN(总 162KB → 66KB,PROGRESS -80%)

PROGRESS 每条压 2-4 句去 smoke 列表 / 文件清单 / 行数明细;DESIGN 清重复 + 删反方向已废弃的取舍段;RUN 压命令注释 + 修 db current 应输出 0005(原写 0004)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 14:20:48 +08:00
parent 2baed6894b
commit e7f9b005bd
3 changed files with 334 additions and 370 deletions

333
DESIGN.md
View File

@ -14,7 +14,7 @@
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) - 模型自由: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/ ├── core/
│ ├── capabilities.py # ModelCapabilities,从 yaml 加载 │ ├── capabilities.py # ModelCapabilities,从 yaml 加载
│ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features │ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features
│ ├── loop.py # ReAct 主循环 │ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel
│ ├── probe.py # 真实探测对账 yaml 声称的能力 │ ├── probe.py # 真实探测对账 yaml 声称的能力
│ ├── session.py # 消息列表 + meta + 落 │ ├── session.py # 消息列表 + meta + 落 PG
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) │ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ └── task.py # TaskState │ ├── 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/ ├── tools/
│ ├── base.py # Tool 基类 + _resolve │ ├── base.py # Tool 基类 + _resolve
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单 │ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess,过滤敏感 env │ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
│ └── skill_tool.py # load_skill │ └── skill_tool.py # load_skill
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets ├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md ├── prompts/system/general_v1.md
├── config/{agent.yaml, models/deepseek_v4.yaml} ├── config/{agent.yaml, models/*.yaml}
├── workspace/ ├── workspace/users/<user_id>/
└── users/<user_id>/ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离
├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离) └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享)
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物 ├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html}
├── core/agent_builder.py # 装配 lib: build_agent / system prompt / validate_task_name ├── db/migrations/ # alembic
└── main.py # 入口: web / db / probe 三子命令 └── main.py # 入口:web / db / probe / user 四子命令
``` ```
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 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/``<storage_root>/`,布局不变。 **工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 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/``<storage_root>/`,布局不变。
**启动**:`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`) ### 3.1 主循环(`core/loop.py`)
ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。 ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。
- 工具结果对模型截 16K 字符,用户预览 400 字符 - 工具结果对模型截 16K 字符,用户预览 400 字符
- 后台 daemon 线程每 100ms 刷 spinner:`thinking... 1.3s ctx 12,345 tok` - 事件通过 `sink.emit` 流式发布(§7 A,SSE 桥)
- 每轮 LLM 返回追加 dim 一行 `[in N out N t Xs]` - `cancel_check: Optional[Callable[[], bool]]` 协作式 cancel,每轮 LLM 前 + tool_calls 之间 poll;命中给未执行 tool_call 补 `[cancelled by user]` 保 LiteLLM 协议
- assistant 文本走 `rich.markdown.Markdown` 整段渲染(非流式)
- `max_iterations` 从 capabilities 读 - `max_iterations` 从 capabilities 读
### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`) ### 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)。 yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式) ### 3.4 工具系统(Hybrid 范式)
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。 **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`)— 批处理 / 算数据 / 生成文档。 **Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
### 3.5 Skill 系统(Anthropic 渐进披露) ### 3.5 Skill 系统(Anthropic 渐进披露)
@ -90,13 +93,9 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
- `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录 - `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录
- `skill` = 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐 `skills/` 注册表强校验) - `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)。
**原子性** —— 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。
### 3.7 双层记忆(`core/memory.py`) ### 3.7 双层记忆(`core/memory.py`)
@ -107,17 +106,15 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) | | Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | | 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**:本地 `workspace/users/<user_id>/.memory/`,SaaS `<storage_root>/users/<user_id>/.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
**memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邮箱密码登录从 users 表直读、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
--- ---
## 4. 模型路由 ## 4. 模型路由
默认 `default_model: deepseek_v4.flash`后续分模式路由思路: 默认 `default_model: deepseek_v4.flash`。分模式路由思路:
| 模式 | 模型 | 理由 | | 模式 | 模型 | 理由 |
|---|---|---| |---|---|---|
@ -125,7 +122,7 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 ——
| 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 | | 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 |
| fallback | claude_4_7.opus | V4 不行时手动切 | | fallback | claude_4_7.opus | V4 不行时手动切 |
成本量级(对比): 成本量级:
| 任务 | flash | pro-max | Opus 4.7 | | 任务 | flash | pro-max | Opus 4.7 |
|---|---|---|---| |---|---|---|---|
@ -144,13 +141,13 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 ——
老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤。 老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤。
### 七条具体原则 ### 七条具体原则
1. Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型 1. Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型
2. Skill 渐进披露,不写完整流程 2. Skill 渐进披露,不写完整流程
3. 工具按原子操作切分,不做高级封装 — 留组合空间 3. 工具按原子操作切分,不做高级封装 — 留组合空间
4. Model Profile 化,不硬编码 4. Model Profile 化,不硬编码
5. Capability Probing 对账实际行为 5. Capability Probing 对账实际行为
6. 版本化 Prompt(等真要切版本时再做) 6. 版本化 Prompt(等真要切版本时再做)
7. ~~eval 评估~~ 已删,dogfooding 更有效 7. ~~eval 评估~~ — 已删,dogfooding 更有效
### 借鉴 ### 借鉴
| 来源 | 借鉴 | | 来源 | 借鉴 |
@ -182,25 +179,22 @@ memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 ——
## 7. SaaS 化(草案,status=design,2026-05-12) ## 7. SaaS 化(草案,status=design,2026-05-12)
> §1-§6 是**本地 dogfood 形态**;本节是**SaaS 形态**,把 core 包成多用户在线服务。 > §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 与本地形态的兼容性 ### 7.0 与本地形态的兼容性
SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本地形态(`python main.py web`)与 SaaS 形态走完全一致的代码路径,无 CLI / in-process 分叉(2026-05-18 撤,详 §7.9)。 SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。
**共享**:同一份 `core/` / `tools/` / SKILL.md / prompts / web `/v1` 路由 / dev SPA。
**差别**:
| 维度 | 本地 | SaaS | | 维度 | 本地 | SaaS |
|---|---|---| |---|---|---|
| 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI | | 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| working_dir | `workspace/users/<user_id>/<name>/`(user_id 从 JWT 透传) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) | | working_dir | `workspace/users/<user_id>/<name>/` | `<storage_root>/users/<user_id>/<name>/` |
| Memory | `workspace/users/<user_id>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) | | Memory | `workspace/users/<user_id>/.memory/` (FS, dotfile) | `<storage_root>/users/<user_id>/.memory/` |
| Sandbox | subprocess + env 过滤 | per-task docker exec | | Sandbox | subprocess + env 过滤 | per-task docker exec |
| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) | | Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。 `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录,不在 storage 形态。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 ### 7.1 心智模型:Task 一等公民 + Dir 文件副视图
@ -208,75 +202,74 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本
| 视图 | 入口语义 | 适用场景 | API | | 视图 | 入口语义 | 适用场景 | API |
|---|---|---|---| |---|---|---|---|
| **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&task_dir=` | | **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&working_dir=` |
| **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` | | **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` + `GET /v1/files` |
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。 类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。
- **Task** = DB 一行,一等公民,自带 `task_dir text` 字段: - **Task** = DB 一行,一等公民,自带 `working_dir` 字段:
- **新建必给 `name`**(简单名),`task_dir = workspace/users/<user_id>/<name>/`。同 name 多 task 共享 → "同一项目多对话"语义;不再支持空 task_dir / 自动 UUID 派生(原 ChatGPT thread 模式取消,纯对话也得起个项目名) - **新建必给 `name`**(简单名),`working_dir = workspace/users/<user_id>/<name>/`(留空 fallback 用 name)。同 working_dir 多 task 共享 → "同一项目多对话"语义
- **指定 → 项目化 task**,同 task_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体) - **指定 → 项目化 task**,同 working_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体)
- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走 prefix cascade(§7.4) - **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走顶层目录 DB-aware 同事务 cascade(§7.4)
- **No-subtask**:同 task_dir 允许(同项目多对话),前缀嵌套拒 - **No-subtask**:同 working_dir 允许(同项目多对话),前缀嵌套拒
- **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload - **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload
- **Skill 产物**全落 task_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件 - **Skill 产物**全落 working_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件
- **Skill 定义**是项目代码,跟部署走,所有用户共享 - **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) ### 7.2 资源模型(/v1)
Task 一等公民(`/v1/tasks*`);files 与 task 正交(§7.1 双视图心智),走 user-rooted `/v1/files*`,以 `workspace/users/<uid>/` 为边界(不强制选 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/<uid>/` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI `/docs` Swagger UI 自查
``` ```
Tasks Tasks
POST /v1/tasks 创建 {name(必填), working_dir?, description?, skill?}; POST /v1/tasks {name(必填), working_dir?, description?, skill?};不合法 → 400
留空 working_dir → 用 name 作目录名;
working_dir 派生 workspace/users/<user_id>/<working_dir>/;
name/working_dir 不合法 → 400
GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering= GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=
列表,返 `{page, page_size, count, results}` 分页 1-based;page_size 1100 clamp;ordering DRF 风格逗号分隔,
分页 1-based;page_size 1100 clamp;status active/completed/abandoned; `-field` 倒序;allowlist created_at/updated_at/name/status;
skill 精确;working_dir 末段名(后端拼前缀比对);q 在 name+description ILIKE; **默认 `-created_at`**;返 `{page, page_size, count, results}`
ordering DRF 风格逗号分隔,`-field` 倒序;allowlist GET /v1/tasks/{id} 单 task meta
created_at/updated_at/name/status;**默认 `-created_at`** PATCH /v1/tasks/{id} {status?,description?,name?,skill?};active 不让从 web 切回
GET /v1/tasks/{id} 单 task meta + 完整 messages DELETE /v1/tasks/{id} 硬删:DB 行 + messages CASCADE;**FS working_dir 保留**
PATCH /v1/tasks/{id} {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI) GET /v1/folders 列当前 user 的 working_dir + 关联 task 计数 + 最后使用时间
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) GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url} POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url}
**单活 run**(0004 简化):tasks.run_status in **单活 run**(0004 简化):tasks.run_status in
('running','cancelling') → 409;'error' 起新 run ('running','cancelling') → 409;`SELECT … FOR UPDATE`
时清(跟 ok 一样视为可重启)。`SELECT … FOR UPDATE` 锁 task 行序列化并发 POST 防 messages.idx race
锁 task 行,序列化并发 POST 防 `messages.idx` race
GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件, GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件,
单活 run 形态下无歧义,客户端只需 task_id 单活 run 形态下无歧义,客户端只需 task_id
POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 `cancelling` + 信号 POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 cancelling + 信号 broker;
broker;BG loop 在工具调用之间 poll 看见即退, BG loop 在工具调用之间 poll 看见即退;
给未执行 tool_call 补 `[cancelled by user]` run_status != running → 409;LLM 同步 call 本身不可中断
tool result(保 LiteLLM 协议),emit `cancelled`
事件;finally 写终态 — 正常 / cancel 都回 `idle`
(不留持久标记),异常才写 `error`
run_status != `running` → 409。
LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完
Files(user-rooted,不绑 task — `workspace/users/<uid>/` 为根) Auth
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root; POST /v1/auth/login {user_id, platform_key} → JWT(platform 机器对机器)
dotfile(`.memory/` 等)一律隐藏(同 /v1/folders 约定) POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
错邮箱 / 错密码 / 未设密码统一 403 防探测
Files(user-rooted,workspace/users/<uid>/ 为根)
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
留空 → user_root;dotfile(`.memory/` 等)一律隐藏
POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename
GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒 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 Export
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
Misc Misc
GET /healthz {"status":"ok"} GET /healthz {"status":"ok"}
GET / 302 → /docs (Swagger UI 自查,本地形态便利) GET / 302 → /static/dev.html(本地 dev SPA)
``` ```
**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`): **SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`):
@ -286,79 +279,72 @@ run_start {}
llm_start {} llm_start {}
text {"delta":"<delta 文本>"} text {"delta":"<delta 文本>"}
tool_call {"name":"...","args":{...},"args_preview":"..."} 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} llm_end {"prompt_tokens":N,"completion_tokens":N}
cancelled {} # cancel 命中,后随 done 收流 cancelled {} # cancel 命中,后随 done 收流
error {"msg":"<type>: <detail>"} error {"msg":"<type>: <detail>"}
done {} 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` 实验位(未启)。 **版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。
**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧。
**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧到 platform 域名 allowlist。 **Auth**:Bearer JWT 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login*`、`/static/*` 豁免。
**Auth**:PLATFORM_KEY → JWT 兑换(过渡形态,见 §7.3);`Authorization: Bearer <jwt>` 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login`、`/static/*` 豁免。
### 7.3 认证 ### 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 <jwt>` 走所有 /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 <jwt>` 走所有 /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 + 本地文件系统 ### 7.4 存储:Postgres + 本地文件系统
```sql ```sql
users(user_id uuid pk, email null unique, password_hash null, oidc_subject null, plan null, created_at) users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at)
-- 0001 schema;0005 给 email 加 UNIQUE(NULL 不冲突,允许 platform_key 入口 user email=NULL 共存)。 -- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
-- 行入口三条:① main.py user add(发邮箱密码用户,bcrypt → password_hash;dev SPA 邮箱密码登录用) -- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id;email/password_hash 留 NULL) -- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
-- ③ 未来 OIDC(D' 真上线后,改写 login;email/oidc_subject 由 ID token 注入)。 -- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
-- 管理:撤用户 DELETE FROM users WHERE email=...(先清该 user 的 tasks,messages CASCADE);
-- 改密 UPDATE users SET password_hash=<bcrypt(...)>;改邮箱 UPDATE users SET email=...(user_id 不动)。
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, 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, status, model_profile, tokens_prompt, tokens_completion, cost_usd,
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表) run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表)
run_error text null, -- error 状态的错误文本,其他状态 NULL run_error text null,
created_at, updated_at); created_at, updated_at);
create index on tasks (user_id, working_dir); create index on tasks (user_id, working_dir);
-- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串 -- working_dir 存储约定:ROOT 内 → 相对 ROOT posix 串;ROOT 外 → 保留绝对
-- (`workspace/users/<user_id>/<name>`,name 是简单名,无 /\..); -- 读写边界统一过 core/paths.py::{to_db_path, from_db_path}
-- 新建强制 `name` 必填,空串只可能在 legacy 数据(开发期已 wipe)。 -- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255
-- SaaS 阶段同理(基础是 <storage_root>/users/<uid>/)。
-- 读写边界统一过 core/paths.py::{to_db_path,from_db_path}。
-- 入口校验 main.py::validate_task_name(): 拒空 / 含 /\NUL / `.` 起头 / >255。
messages(message_id uuid pk, task_id fk, idx int not null, messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out, created_at, payload jsonb not null, tokens_in, tokens_out, created_at,
unique (task_id, idx)); unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops); create index on messages using gin (payload jsonb_path_ops);
-- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步)
``` ```
**0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs` **0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`usage_events` 是计费预付架构成本,真要计费再加。`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。
角色等价于"task 当前 in-flight 状态",合并到 `tasks.run_status` + `tasks.run_error` 两列; **run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。
`usage_events` 是为未来计费预付的架构成本,真要计费再加,DB schema 改动便宜。`run_id`
取消 —— 单活 run 形态下它对客户端 / broker / cancel 全是冗余字段。
**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。 **文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根):
**文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根,布局不变):
``` ```
<storage_root>/users/<user_id>/ <storage_root>/users/<user_id>/
.memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB .memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
<name>/ # 项目目录,name 用户起(必填),task_dir 直接落这 <name>/ # 项目目录,name 用户起(必填),working_dir 直接落这
<name>/... # 同 name 多 task 共享同目录(§7.1) # 同 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 ### 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 | `<storage_root>/users/<user_id>/``/workspace`;同 user 多 task 不互隔(协作方便),跨 user 由独立实例隔离 | | bind mount = user root | `<storage_root>/users/<user_id>/``/workspace`;同 user 多 task 不互隔(协作方便),跨 user 由独立实例隔离 |
**资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。 **资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。Executor Protocol 抽象后切换成本低。
### 7.6 Core 代码改造(按依赖顺序) ### 7.6 Core 代码改造(按依赖顺序)
| # | 项 | 估时 | | # | 项 | 状态 |
|---|---|---| |---|---|---|
| 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done | | 1 | 事件流化 `loop.py` | ✅ done |
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 | | 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) | ✅ done |
| 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `<storage_root>/users/<user_id>/<name>/`(本地 `<storage_root>` = `workspace/`,user_id 走 JWT);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 | | 3 | working_dir 字段语义(name 必填,派生 `users/<uid>/<name>/`,同 name 共享) | ✅ done |
| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 | | 4 | Files API(list/upload/download/delete/rename,user-rooted) | ✅ done |
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 | | 5 | No-subtask 校验 | ✅ done |
| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | | 6 | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 待 |
| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 4 天 | | 7 | HTTP /v1 surface | ✅ done |
| 8 | ~~**CLI 双模式**~~ —— **删**(2026-05-18):dev SPA 起后浏览器一直开着,CLI REPL `chat/tasks/export` 三命令已撤;`main.py` 入口只剩 `web / db / probe`,无双 transport(§7.9) | 已撤 | | 8 | ~~CLI 双模式~~ | 撤(§7.9) |
| 9 | ~~Web UI 简洁版(Jinja2+HTMX)~~ → 改为 **API surface 完工**:Phase G 落地的模板 / HTMX / 服务端 markdown 渲染删除,所有路由切纯 JSON;UI 由 platform 端实现(§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 分阶段落地 ### 7.7 分阶段落地
| 阶段 | 范围 | 工作量 | 验收 | | 阶段 | 范围 | 状态 |
|---|---|---|---| |---|---|---|
| A | #1 事件流化 | done ✅ | sink 协议铺 SSE 路 | | A | 事件流化 | ✅ |
| B | #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + no-subtask)| done ✅ | 本地走 PG,messages 进 DB,任务/消息/状态全在 PG;task_dir 改相对存储(§7.4 注释)| | B | Storage 落 PG + working_dir 语义 + no-subtask | ✅ |
| D | #7 HTTP /v1 surface(无 auth)| done ✅ | `/v1/tasks/*` + SSE JSON + files 4 路由 + export + Swagger;本地形态走 JWT 跑通 | | D | HTTP /v1 surface | ✅ |
| 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' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | ✅ |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 | | D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天,发布前必做 |
| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 | | C | Executor + sandbox | 3 天 |
| ~~E~~ | ~~CLI transport 双模式~~ **撤**(2026-05-18,§7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 整套删,`main.py` 入口只剩 web/db/probe | — | | ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) |
| ~~G~~ | ~~Web UI 简洁版~~ —— **删除**,前端由 platform 端实现 | — | 本仓库不维护 UI | | ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) |
| F | 上线打磨(限流 / 监控 / 告警 / HA)| 持续 | SLO 99.5% | | F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 |
**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。 **B 阶段一次性切换**:切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨,dogfood 即生效。
**D 落在 G 前面**:原排期 D 在 G 后(以为 dogfood 用 UI 跑),转向"platform 端联调"后 API surface 反而成阻塞;G 的 Jinja2+HTMX 投入沉淀的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
**D 落在 G 前面** —— 原排期 D 在 G 后(以为 dogfood 用 UI 跑),实际转向"platform 端联调"后,API surface 反而成阻塞;G 的 Jinja2+HTMX 投入(G1-G6 ~3 天)沉淀 = 删除前的 dogfood 价值,留下的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
### 7.8 已知风险 ### 7.8 已知风险
| 风险 | 缓解 | | 风险 | 缓解 |
|---|---| |---|---|
| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL(原 §7.6 #8 双模式)2026-05-18 整套撤,无双 transport 维护税 | | 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL 整套撤无双 transport 维护税 |
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 | | `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation |
| Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 | | Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 |
| Running task 被 rename / delete | 后端校验 + UI 禁按钮 | | Running task 被 rename / delete | 后端校验 + UI 禁按钮(详 §7.4) |
| 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin | | 误删 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 并发写同名 | 文件级悲观锁,冲突早失败 | | 同 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 | | 同 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` 协作式 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 本身不可中断 — 接受最坏等当前一轮跑完(几十秒) | | Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式;LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完(几十秒) |
| Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI | | Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI |
| 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 | | 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 |
### 7.9 取舍说明 ### 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 等价。 **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 决策): **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`(届时是新需求,不是技术债)。
- **`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` 清掉。
**本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。 **本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。
**API-only,UI 由 platform 实现**(2026-05-15 决策): **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 层牵连。
- **原计划**: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 层不被牵连
**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 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 命令。
- **原计划**:`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,跟之前一样,不靠"全栈零依赖"幻觉。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 **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 看到的目录,无中间层翻译。
--- ---

File diff suppressed because one or more lines are too long

167
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-19(**dev SPA 登录撤回 邮箱+密码** — 复用 0001 schema 的 `users.email/password_hash`,0005 加 `UNIQUE(email)`;短命 invites 表 + `/v1/auth/login_invite` 撤;新路由 `POST /v1/auth/login_password`;`main.py user add` CLI 建用户;`/v1/auth/login` (platform 机器对机器) 不动;SENTINEL user 撤) 最后更新:2026-05-19(dev SPA 登录改 邮箱+密码;`POST /v1/auth/login_password`;`main.py user add` CLI)
--- ---
@ -13,18 +13,17 @@
``` ```
DEEPSEEK_API_KEY=sk-... DEEPSEEK_API_KEY=sk-...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# main.py web 必填(probe/db 用不到,只在起 web 时校验) # main.py web 必填(probe/db/user 不验)
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端持有,机器对机器入口校验> PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验>
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护> JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
# 可选:覆盖默认 7d JWT TTL # 可选:覆盖默认 7d JWT TTL
# ZCBOT_JWT_TTL_SECONDS=604800 # ZCBOT_JWT_TTL_SECONDS=604800
``` ```
> 邀请码方案已撤(05-19,一日游),改回 `users.email/password_hash`(0001 schema 原列 + 0005 加 UNIQUE)。 > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...` - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt` 给密码哈希)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe / user` 不验,不要这两个 env 也能跑。 - **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户走 `main.py user add`(下方);撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。改密 / 改邮箱手动 SQL 或先 DELETE 再 add。
- **邮箱密码**(`users.email/password_hash`,0005 加 UNIQUE):dev SPA 登录后端。一行 = 一个用户,`password_hash` 是 `bcrypt`(cost=12)。发用户走 `.venv/Scripts/python.exe main.py user add --email X --password Y`(详见下方 user 命令段);撤用户直接 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks,messages 通过 FK CASCADE 自动)。同事拿到邮箱密码即可登录,**不接触** `PLATFORM_KEY`。改密 / 改邮箱目前手动 SQL(`UPDATE users SET password_hash=<bcrypt hash> / email=... WHERE ...`)或先 DELETE 再 add。
--- ---
@ -39,33 +38,28 @@ python -m venv .venv
# 3) DB schema 上车 # 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head .venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db current # 应输出 0004 (head) .venv/Scripts/python.exe main.py db current # 应输出 0005 (head)
``` ```
--- ---
## 日常命令 ## 日常命令
> 入口统一在 `main.py`,只剩三个子命令:`web / db / probe`。所有 task / 消息 / 文件交互 > 入口 `main.py {web, db, probe, user}`。所有 task / 消息 / 文件交互走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`
> 走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`(下方 Web API 段)。
### Web 服务(§7 D + D' 过渡 auth) ### Web 服务
```bash ```bash
# 默认 127.0.0.1:8765;dev SPA 在 /,Swagger UI 在 /docs # 默认 127.0.0.1:8765;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe main.py web .venv/Scripts/python.exe main.py web
# 自定义端口 / 监听 0.0.0.0(慎用,部署形态走反代不直暴)
.venv/Scripts/python.exe main.py web --port 9000 .venv/Scripts/python.exe main.py web --port 9000
.venv/Scripts/python.exe main.py web --reload # 文件改动自动重启
# dev:文件改动自动重启(uvicorn 工厂模式 reload)
.venv/Scripts/python.exe main.py web --reload
``` ```
### 能力探测 / DB 管理 / 用户管理 ### DB / probe / user
```bash ```bash
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销) # 模型能力对账(费 token)
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash .venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
# DB migration # DB migration
@ -73,72 +67,72 @@ python -m venv .venv
.venv/Scripts/python.exe main.py db downgrade -1 .venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe main.py db current .venv/Scripts/python.exe main.py db current
# 发用户 — dev SPA 邮箱密码登录后端 bootstrap # 发用户
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6" .venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com user_id=<uuid> # → [ok] user added email=alice@example.com user_id=<uuid>
# 可选把已有 user_id(platform_key 入口创的)接到邮箱密码路径
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID> .venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 撤用户(先清该 user 的 tasks,messages CASCADE)
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com'); # psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com'; # psql> DELETE FROM users WHERE email='alice@example.com';
``` ```
**Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*``Authorization: Bearer <jwt>` ### Auth + curl 用 token 调 /v1/*
两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*``Authorization: Bearer <jwt>`
```bash ```bash
# 路径 1:邮箱密码(dev SPA 给同事 / 自己试用 — 推荐) # 路径 1:邮箱密码(dev SPA / 同事试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \ curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"atLeast6"}' -d '{"email":"alice@example.com","password":"atLeast6"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","email":"alice@example.com","ttl_seconds":604800}
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口) # 路径 2:platform_key + 指定 user_id(机器对机器)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \ curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}' -d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800}
# 用 token 调 /v1/* # 调 /v1/*
TOKEN="eyJ..." TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
``` ```
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY")进入 3 栏(task 列表 / chat / files);last-used tab 持久化在 localStorage。给同事试用的最简发用户流程: **dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:`main.py user add` 发用户,**不用重启** web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。
1. `.venv/Scripts/python.exe main.py user add --email <每人> --password <每人>`
2. **不用重启** web(每次 login 都查 DB),把 URL + 各自邮箱密码分别发给同事
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`): ### 路由表
全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `/docs`
| 方法 + 路径 | 用途 | Auth | | 方法 + 路径 | 用途 | Auth |
|---|---|---| |---|---|---|
| `GET /healthz` | `{"status":"ok"}` 健康检查 | 豁免 | | `GET /healthz` | `{"status":"ok"}` | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 | | `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 | | `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 | | `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | platform 机器对机器入口;body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/auth/login_password` | dev SPA 邮箱密码入口;body `{email, password}``{token,expires_at,user_id,email,ttl_seconds}`;邮箱不存在 / 密码错 / 未设密码统一 403 `invalid email or password` | 豁免 | | `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}``{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 |
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 | | `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`page` 1-based,`page_size` 1100;`working_dir` 末段名;`q` ILIKE name+desc;`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 走 CLI 切回 | 必填 | | `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),FS working_dir 保留 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used(供创建 task 自动补全用) | 必填 | | `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}`;**`tasks.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` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 — 单活 run 形态下无歧义 | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel 当前活跃 run;返 `{ok, task_id, run_status:"cancelling"}`;`run_status != "running"` → 409;LLM 同步 call 本身不可中断,最坏等当前一轮跑完 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 同步 call 不可中断,最坏等当前一轮跑完 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下子目录条目 + 面包屑(user-rooted,不绑 task);dotfile 隐藏 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件(user_root 下) | 必填 | | `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` | body `{path}`;文件或空目录;**path 顶层目录(user_root 直接子项,为目录)且仍被 task 引用 working_dir → 409**,先 DELETE 关联 task | 必填 | | `POST /v1/files/delete` | `{path}`;文件或空目录;**path 顶层目录且被 task 引用 → 409**,先 DELETE 关联 task | 必填 |
| `POST /v1/files/rename` | body `{path, new_name}`;`new_name` 是新 leaf 名(校验同 task_name);sibling 已存在 → 409;**path 是顶层目录** → 同步 `UPDATE tasks.working_dir`(同事务 + FOR UPDATE 锁;有 running/cancelling task → 409;`check_no_subtask` 防嵌套 → 409);非顶层(子目录 / 文件)纯 FS rename | 必填 | | `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 | 必填 |
**SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 命中`cancelled{}` 后随 `done{}` 收流;异常路径`error{msg}`。30s 无 event 服务端发 `: ping` 注释心跳。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 **SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。 **SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query(目前不支持,避免 token 进 access log)。
> 原 Phase G Jinja2 + HTMX Web UI 路线撤(DESIGN §7.9 取舍说明)—— UI 改 platform 端实现,本仓库只维护 API + 一个 dev SPA。`main.py web` 跑的是 API + Swagger + dev.html。
--- ---
@ -146,53 +140,52 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| 现象 | 原因 / 处理 | | 现象 | 原因 / 处理 |
|---|---| |---|---|
| `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发。直跑脚本时 `import litellm`,`export ZCBOT_DB_URL=...` | | `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发。直跑脚本时 `import litellm` `export ZCBOT_DB_URL=...` |
| `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` | | `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` |
| Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[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` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--working-dir` 指定后 task 删了目录还在 | 设计如此 — 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
| `NoSubtaskError: working_dir ... 与已有 task ... 前缀嵌套` | §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``{"status":"ok"}` | | `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` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`(nginx ≥1.5.6 默认认)。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)| | platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 |
| `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或点 dev SPA 的 stop / `POST /v1/tasks/{id}/cancel`;服务异常下 `tasks.run_status` 卡 `running`/`cancelling`,启动 reaper 会清 | | `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status` 卡 `running`/`cancelling`,启动 reaper 会清 |
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel,error 只能起新 run 顶掉);dev SPA 自动忽略不报错 | | `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 | | 点 stop 后流式没立刻停 | LLM 同步 call 不可中断,最坏等当前一轮跑完(几十秒);loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop |
| `[startup] reaped N stale active run(s)` | 上次 `main.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录(user_root 直接子项)被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完所有关联 task 再删目录。子目录不受此限,可直接删空目录 | | `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 占用;先 stop / `POST /v1/tasks/{id}/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 形成嵌套(§7.4 no-subtask)。换一个不冲突的 new_name | | `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
| `main.py web` 启动报 `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` 重起 |
| `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱在 users 表里不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 → `main.py user add` 发新;有行无密码 → `UPDATE users SET password_hash=...` 手补 bcrypt(用 `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` 算)或直接 `user add --user-id` 接到现有 user_id | | `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 → `main.py user add`;有行无密码 → `UPDATE users SET password_hash=...`(用 `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` 算)或 `user add --user-id` 接到现有 user_id |
| `main.py user add``IntegrityError ... uq_users_email` | 邮箱已在 users 表里,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks);允许同邮箱不同 user 是漏 | | `main.py user add``IntegrityError ... uq_users_email` | 邮箱已在,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks) |
| `main.py user add``IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传 `--user-id` 让随机生成 | | `main.py user add``IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
| 改了用户邮箱后他登不上 | 单纯改 `UPDATE users SET email=...` 不影响 user_id(行还是同一行,task 仍归属),用户用新邮箱登即可;若 lowercase 不一致(后端 `lower()` 后查)→ DB 里就该存小写。改密同理 `UPDATE users SET password_hash=<bcrypt>` | | 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` | | `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env | | `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login | | dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,按上次用的 tab(邮箱密码 / UUID+PLATFORM_KEY)重登即可 | | dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
--- ---
## 关键路径与文件 ## 关键路径与文件
- **入口**:`main.py`(`web / db / probe / user` 四子命令)→ `core/agent_builder.py::build_agent`(装配 lib) - **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent`
- **核心**:`core/agent_builder.py`(build_agent / system prompt / validate_task_name 等装配 lib)/ `core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) - **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/`
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py` - **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic) - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx)
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS) - **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile)
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub` — 邮箱密码登录从 users 表直读、platform 登录直传): - **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` 跨 task 记忆,FS 永久,dotfile 隔离 - `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/<working_dir>/` 工作目录,用户起的目录(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享 - `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享
--- ---
## 维护约定 ## 维护约定
- **一个对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局)→ 同步更新本文档**。bug 修不动这个,只动 PROGRESS。 - **改对外行为(CLI 选项 / env / 文件布局)→ 同步本文档**。bug 修不动这个,只动 PROGRESS。
- 故障兜底新增条目:用过一次的真实坑,写一行(现象 + 处理),不预测。 - 故障兜底新增:用过一次的真实坑,写一行,不预测。
- 跟 DESIGN/PROGRESS 边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。 - 跟 DESIGN/PROGRESS 边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。