diff --git a/DESIGN.md b/DESIGN.md index 5e6b8ba..d9ef760 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 + sentinel JWT,跟 SaaS 走完全一致的路径(只是 user_id 一直填 sentinel),无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) +- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邀请码登录(`invites` 表,name → uuid5 推导 user_id),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) --- @@ -47,9 +47,9 @@ zcbot/ └── main.py # 入口: web / db / probe 三子命令 ``` -**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`;dev SPA 默认填 SENTINEL(`00000000-...`)走同一条路径,无本地 / 远程分叉。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/` 换 `/`,布局不变。 +**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邀请码登录(`invites` 表)由 name 经 `uuid5(固定 namespace, name)` 推导,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` 换 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_invite`(dev SPA / 同事试用)或 `/v1/auth/login`(platform 机器对机器)换 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 / 生产)。 --- @@ -111,7 +111,7 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 -**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。本地 CLI 走 SENTINEL user;web/JWT 走 `sub`。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 +**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邀请码登录走 uuid5、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 --- @@ -195,10 +195,10 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本 |---|---|---| | 入口 | `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///`(dev SPA 默认填 SENTINEL) | `/users///`(JWT `sub`) | -| Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.memory/`(仍是 FS,dotfile) | +| working_dir | `workspace/users///`(user_id 从 JWT 透传) | `/users///`(JWT `sub`) | +| Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.memory/`(仍是 FS,dotfile) | | Sandbox | subprocess + env 过滤 | per-task docker exec | -| Auth | PLATFORM_KEY → JWT(过渡)— dev SPA 填 sentinel + 本地 key | 同 — platform 端服务端持 key 签 JWT | +| Auth | 邀请码(`invites` 表,name→uuid5)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邀请码同步下线) | `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录(`workspace/` vs `/`),不在 storage 形态。 @@ -311,7 +311,7 @@ done {} ```sql users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at) --- 本地形态固定 INSERT sentinel: user_id = '00000000-...',email/auth/plan 全 NULL +-- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC 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, @@ -331,6 +331,11 @@ messages(message_id uuid pk, task_id fk, idx int not null, unique (task_id, idx)); create index on messages using gin (payload jsonb_path_ops); -- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步) + +invites(token text pk, name text not null unique, created_at) +-- 0005 加;dev SPA 邀请码登录后端。user_id 由 uuid5(固定 NS, name) 推导,不入表。 +-- 管理:直接 INSERT/DELETE/UPDATE(后续若需要可加 `python main.py invite ...` 薄包装); +-- 撤销 = DELETE row;换 token 不换身份 = UPDATE token;换 name = 换身份(旧 task 留在旧 user 下)。 ``` **0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs` 表 @@ -374,7 +379,7 @@ create index on messages using gin (payload jsonb_path_ops); |---|---|---| | 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/`,sentinel user);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 | +| 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 天 | @@ -390,7 +395,7 @@ create index on messages using gin (payload jsonb_path_ops); |---|---|---|---| | 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;本地形态 sentinel user 跑通 | +| 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 仍可用 | @@ -443,11 +448,11 @@ create index on messages using gin (payload jsonb_path_ops); - 删 `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`) - - 本地 sentinel user 形态保留;auth 走 D' 过渡形态(PLATFORM_KEY → JWT,见 §7.3),真 OIDC 留到联调约定 token 形态后接 + - auth 走 D' 过渡形态:邀请码(`invites` 表,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)。形态:登录页填 user_id(默 sentinel)+ platform_key → localStorage 存 JWT → fetch+Bearer。 +**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)。形态:登录页填邀请码 → `/v1/auth/login_invite` 拿 JWT → localStorage 存 → fetch+Bearer。 **CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"): - **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。 @@ -456,7 +461,7 @@ create index on messages using gin (payload jsonb_path_ops); - `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 默认填 SENTINEL 走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。 +- **失**: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"事实由用户判断"。 diff --git a/PROGRESS.md b/PROGRESS.md index ff34692..3775f8d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,9 @@ ## 已完成关键能力 +- **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 → `