auth(dev SPA): 邀请码登录(invites 表 0005) + SENTINEL user 彻底撤
- 新增 POST /v1/auth/login_invite {token}: dev SPA 给同事试用,token → name → uuid5(NS, name) 推导 user_id;原 /v1/auth/login 保留为 platform 机器对机器入口
- 0005 migration 新表 invites(token PK / name UNIQUE / created_at);先用 ZCBOT_INVITES env 试了一版,讨论后升级到 DB 表 — schema 极薄,不入 user_id (uuid5 推导),不入 revoked_at (DELETE 即撤销);管理直接 SQL,后期可加 main.py invite CLI
- web/auth.py: 删 _parse_invites / AuthConfig.invites / env 读取;新模块函数 resolve_invite(token) 每次 SELECT,无缓存避免 DELETE 后还能登
- SENTINEL_USER_ID 常量 + ensure_local_sentinel 函数 + agent_builder fallback 全删 (CLI 撤后无 caller);storage/utils.py 三函数 user_id 改必填;TaskState 加 user_id 字段;build_agent user_id 改 KEYWORD_ONLY 必填;session.py 删多余 ensure_local_task_row (task 行 web 入口已 INSERT)
- DB 清: SENTINEL 行 + 5 个 dev task + 307 messages + workspace/users/00000000.../ 全删
- dev.html: 登录页 2 格 (uuid+key) → 1 格邀请码,header 显示 name·uuid 前 8 位
- 文档全套同步: RUN/DESIGN/PROGRESS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f61503fbdb
commit
53f59eb78a
31
DESIGN.md
31
DESIGN.md
|
|
@ -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 + 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 三子命令
|
└── main.py # 入口: web / db / probe 三子命令
|
||||||
```
|
```
|
||||||
|
|
||||||
**工作目录(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`;dev SPA 默认填 SENTINEL(`00000000-...`)走同一条路径,无本地 / 远程分叉。**`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(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/` 换 `<storage_root>/`,布局不变。
|
||||||
|
|
||||||
**启动**:`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 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。
|
||||||
|
|
||||||
**memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。本地 CLI 走 SENTINEL user;web/JWT 走 `sub`。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `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` 透传(邀请码登录走 uuid5、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `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 |
|
| 入口 | `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/<sentinel>/<name>/`(dev SPA 默认填 SENTINEL) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) |
|
| working_dir | `workspace/users/<user_id>/<name>/`(user_id 从 JWT 透传) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) |
|
||||||
| Memory | `workspace/users/<sentinel>/.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/`(仍是 FS,dotfile) |
|
||||||
| Sandbox | subprocess + env 过滤 | per-task docker exec |
|
| 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/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
|
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ done {}
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
|
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,
|
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,
|
||||||
|
|
@ -331,6 +331,11 @@ messages(message_id uuid pk, task_id fk, idx int not null,
|
||||||
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 起步)
|
-- 全文搜按需加 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` 表
|
**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 |
|
| 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done |
|
||||||
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 |
|
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 |
|
||||||
| 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `<storage_root>/users/<user_id>/<name>/`(本地 `<storage_root>` = `workspace/`,sentinel user);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 |
|
| 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 天 |
|
||||||
| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 |
|
| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 |
|
||||||
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 |
|
| 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 天 |
|
| 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 路 |
|
| 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 注释)|
|
| 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' 过渡 | 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 内部 |
|
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天 | 真发布给真实用户前补;路由层 Depends 不动,只换 login 内部 |
|
||||||
| C | #6 Executor + sandbox | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
|
| 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 依赖
|
- 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
|
||||||
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
|
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
|
||||||
- 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`)
|
- 路由统一 `/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 域名收紧
|
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
|
||||||
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
|
- **沉淀**: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 REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"):
|
||||||
- **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。
|
- **原计划**:`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)。
|
- `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。
|
||||||
- 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。
|
- 删 `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 行。
|
- 净减 ~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,跟之前一样,不靠"全栈零依赖"幻觉。
|
- **离线**:本地 `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"事实由用户判断"。
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
35
RUN.md
35
RUN.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||||
|
|
||||||
最后更新:2026-05-18(`cli.py` 改名 `main.py`(入口),原 `main.py` 挪到 `core/agent_builder.py`(装配 lib);CLI 子命令 `chat / tasks / export` 删 — 全走 web `/v1` + dev SPA;`main.py` 只剩 `web / db / probe` 三命令)
|
最后更新:2026-05-19(dev SPA 登录改邀请码;**邀请码后端从 env 升级到 `invites` 表**(0005 migration);新路由 `POST /v1/auth/login_invite`,原 `/v1/auth/login` 留给 platform 机器对机器入口;SENTINEL user 撤)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -14,15 +14,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 用不到,只在起 web 时校验)
|
||||||
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端 / dev 浏览器持有,登录时校验>
|
PLATFORM_KEY=<至少 16 字符的随机串,platform 服务端持有,机器对机器入口校验>
|
||||||
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
|
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护>
|
||||||
# 可选:覆盖默认 7d
|
# 可选:覆盖默认 7d JWT TTL
|
||||||
# ZCBOT_JWT_TTL_SECONDS=604800
|
# ZCBOT_JWT_TTL_SECONDS=604800
|
||||||
```
|
```
|
||||||
|
> 邀请码不再走 env(05-19 撤),改走 `invites` 表(见下方"邀请码"段)。
|
||||||
> 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` 里)。
|
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
|
||||||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
|
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
|
||||||
- **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` 不验,不要这两个 env 也能跑。
|
- **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` 不验,不要这两个 env 也能跑。
|
||||||
|
- **邀请码**(`invites` 表,0005):dev SPA 登录后端。schema 极薄 — `token PK / name UNIQUE / created_at`。user_id 由 `uuid5(固定 namespace, name)` 推导,不入表(重启稳定)。空表 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`;撤销:`DELETE FROM invites WHERE name=...`)。同事拿到 token 即可登录,**不接触** `PLATFORM_KEY`。token 生成可用 `python -c "import secrets;print(secrets.token_urlsafe(16))"`。后续可选加 `python main.py invite {add|list|revoke}` 薄包装。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -72,13 +74,19 @@ python -m venv .venv
|
||||||
.venv/Scripts/python.exe main.py db current
|
.venv/Scripts/python.exe main.py db current
|
||||||
```
|
```
|
||||||
|
|
||||||
**Auth**:所有 `/v1/tasks*` 需 `Authorization: Bearer <jwt>`;先走 `/v1/auth/login` 拿 token:
|
**Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*` 需 `Authorization: Bearer <jwt>`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 登录 → 拿 token(本地默 user_id = sentinel 全 0)
|
# 路径 1:邀请码(dev SPA 给同事 / 自己试用 — 推荐)
|
||||||
|
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_invite \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token":"<同事的邀请码>"}'
|
||||||
|
# → {"token":"eyJ...","expires_at":"...","user_id":"...","name":"alice","ttl_seconds":604800}
|
||||||
|
|
||||||
|
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口)
|
||||||
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":"00000000-0000-0000-0000-000000000000","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":"eyJ...","expires_at":"...","user_id":"...","ttl_seconds":604800}
|
||||||
|
|
||||||
# 用 token 调 /v1/*
|
# 用 token 调 /v1/*
|
||||||
|
|
@ -86,7 +94,10 @@ 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`),login 表单填 user_id(默 sentinel)+ PLATFORM_KEY 进入 3 栏(task 列表 / chat / files)。仅给开发自验,不发布给真用户。
|
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),login 表单填邀请码(需 `invites` 表里有对应行)进入 3 栏(task 列表 / chat / files)。给同事试用的最简发码流程:
|
||||||
|
1. `python -c "import secrets;print(secrets.token_urlsafe(16))"` 生成 token
|
||||||
|
2. PG 里 `INSERT INTO invites(token, name) VALUES('<tokenN>', '<nameN>');`(每人一条;name 是显示名,推导固定 user_id 用 — 建议英文 / 拼音 / 短)
|
||||||
|
3. **不用重启** web(每次 login 都查 DB),把 URL + 各自 token 分别发给同事
|
||||||
|
|
||||||
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
|
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
|
||||||
|
|
||||||
|
|
@ -96,7 +107,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
||||||
| `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` | 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_invite` | dev SPA 邀请码入口;body `{token}` → `{token,expires_at,user_id,name,ttl_seconds}`;`invites` 表 token 未命中(含空表)→ 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` 1–100;`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}`;`page` 1-based,`page_size` 1–100;`working_dir` 末段名;`q` ILIKE name+desc;`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 | 必填 |
|
||||||
|
|
@ -145,6 +157,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
||||||
| `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 占用;先点 stop / `POST /v1/tasks/{id}/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 形成嵌套(§7.4 no-subtask)。换一个不冲突的 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` 重起 |
|
| `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` 重起 |
|
||||||
|
| `/v1/auth/login_invite` 返 403 `invalid invite token` | `invites` 表无此 token(从未 INSERT 或已 DELETE)。`SELECT token, name, created_at FROM invites` 核对 |
|
||||||
|
| INSERT invites 报 `duplicate key value violates unique constraint` | token PK 或 name UNIQUE 撞,改值再试 — 重复 token = 两人同身份漏,重复 name = 两 token 共身份漏,都被 schema 拦 |
|
||||||
|
| 改了同事的 name 后他登不上 / 数据看不到 | user_id 由 `uuid5(namespace, name)` 推导,改 name 等于换身份(`UPDATE invites SET name=...` = 换 user)。要保留数据:别动 name,只 `UPDATE invites SET token=...`(换 token = 不换身份);真要换身份:告知"这是新账号,旧任务在另一个 user 下" |
|
||||||
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
|
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/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 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login |
|
||||||
|
|
@ -161,9 +176,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
||||||
- **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)
|
- **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 子树,本地 CLI sentinel = `00000000-0000-0000-0000-000000000000`,web/JWT 用 sub):
|
- **Workspace**(per-user 子树,user_id 来自 JWT `sub` — 邀请码登录走 uuid5、platform 登录直传):
|
||||||
- `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>/` —— 工作目录,用户起的目录名(`cli chat --working-dir` 或留空 fallback `--name` / API `POST /v1/tasks {working_dir?}`),同 working_dir 多 task 共享
|
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
workspace/users/<user_id>/<working_dir>/ ← 工作目录(用户起名,可多 task 共享)
|
workspace/users/<user_id>/<working_dir>/ ← 工作目录(用户起名,可多 task 共享)
|
||||||
workspace/users/<user_id>/.memory/{core.md, extended/} ← per-user 记忆(dotfile 隔离)
|
workspace/users/<user_id>/.memory/{core.md, extended/} ← per-user 记忆(dotfile 隔离)
|
||||||
|
|
||||||
所有入口都走 web `/v1` + JWT(user_id = sub);本地 dev SPA 默认填 SENTINEL
|
所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邀请码登录(`invites` 表,
|
||||||
(`00000000-...`)走同一条路径。task_id / user_id 全 UUID;state.json 已删除
|
name → uuid5)、platform 服务端走 platform_key 登录。task_id / user_id 全 UUID;
|
||||||
(元数据全在 PG)。
|
state.json 已删除(元数据全在 PG)。
|
||||||
|
|
||||||
**新建 task 必须给 `name`**(任务显示名,DB 列 NOT NULL);**`working_dir` 可选**
|
**新建 task 必须给 `name`**(任务显示名,DB 列 NOT NULL);**`working_dir` 可选**
|
||||||
(留空 → 用 name 作目录名;同 working_dir 多 task 自动共享 §7.1)。name 和 working_dir
|
(留空 → 用 name 作目录名;同 working_dir 多 task 自动共享 §7.1)。name 和 working_dir
|
||||||
|
|
@ -33,7 +33,7 @@ from core.paths import ROOT, from_db_path, to_db_path
|
||||||
from core.session import Session
|
from core.session import Session
|
||||||
from core.sinks import ConsoleEventSink
|
from core.sinks import ConsoleEventSink
|
||||||
from core.skills import SkillRegistry
|
from core.skills import SkillRegistry
|
||||||
from core.storage import SENTINEL_USER_ID, check_no_subtask, ensure_local_sentinel
|
from core.storage import check_no_subtask
|
||||||
from core.task import TaskState
|
from core.task import TaskState
|
||||||
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
||||||
from tools.run_python import RunPythonTool
|
from tools.run_python import RunPythonTool
|
||||||
|
|
@ -163,6 +163,8 @@ def _build_system_prompt(
|
||||||
|
|
||||||
|
|
||||||
def build_agent(
|
def build_agent(
|
||||||
|
*,
|
||||||
|
user_id: UUID,
|
||||||
model_name: Optional[str] = None,
|
model_name: Optional[str] = None,
|
||||||
workspace: Optional[str] = None,
|
workspace: Optional[str] = None,
|
||||||
console: Optional[Console] = None,
|
console: Optional[Console] = None,
|
||||||
|
|
@ -173,7 +175,6 @@ def build_agent(
|
||||||
description: str = "",
|
description: str = "",
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
working_dir: Optional[str] = None,
|
working_dir: Optional[str] = None,
|
||||||
user_id: Optional[UUID] = None,
|
|
||||||
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
|
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
|
||||||
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
|
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
|
||||||
|
|
||||||
|
|
@ -182,15 +183,12 @@ def build_agent(
|
||||||
- `working_dir` 可选(留空 → fallback 用 name 作目录名;非空也走 validate_task_name)
|
- `working_dir` 可选(留空 → fallback 用 name 作目录名;非空也走 validate_task_name)
|
||||||
Resume:name / working_dir 都忽略(从 DB 读)。
|
Resume:name / working_dir 都忽略(从 DB 读)。
|
||||||
|
|
||||||
`user_id` 决定 working_dir 根、memory 子树、no-subtask 校验作用域。
|
`user_id` 必填,决定 working_dir 根、memory 子树、no-subtask 校验作用域。
|
||||||
None → SENTINEL(本地 CLI)。web 入口必须显式传入 JWT user_id。
|
web 入口从 JWT 拿到后透传;不允许无 user 的调用路径。
|
||||||
"""
|
"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
model = model_name or cfg["default_model"]
|
model = model_name or cfg["default_model"]
|
||||||
uid = user_id or SENTINEL_USER_ID
|
uid = user_id
|
||||||
|
|
||||||
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口
|
|
||||||
ensure_local_sentinel()
|
|
||||||
|
|
||||||
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
|
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
|
||||||
llm = LLM(caps)
|
llm = LLM(caps)
|
||||||
|
|
@ -253,7 +251,7 @@ def build_agent(
|
||||||
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
|
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
|
||||||
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
|
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
|
||||||
task_state = TaskState(
|
task_state = TaskState(
|
||||||
task_id=sid, name="", working_dir=wd_db,
|
task_id=sid, user_id=uid, name="", working_dir=wd_db,
|
||||||
skill=skill, description=description, status="active",
|
skill=skill, description=description, status="active",
|
||||||
model=caps.model_id, model_profile=model,
|
model=caps.model_id, model_profile=model,
|
||||||
)
|
)
|
||||||
|
|
@ -265,7 +263,7 @@ def build_agent(
|
||||||
# ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens
|
# ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens
|
||||||
# 或 /done /desc 走 upsert 覆盖完整字段。
|
# 或 /done /desc 走 upsert 覆盖完整字段。
|
||||||
task_state = TaskState(
|
task_state = TaskState(
|
||||||
task_id=sid, name=task_name_safe, working_dir=wd_db,
|
task_id=sid, user_id=uid, name=task_name_safe, working_dir=wd_db,
|
||||||
skill=skill, description=description, status="active",
|
skill=skill, description=description, status="active",
|
||||||
model=caps.model_id, model_profile=model,
|
model=caps.model_id, model_profile=model,
|
||||||
reasoning_effort=caps.default_reasoning_effort or "",
|
reasoning_effort=caps.default_reasoning_effort or "",
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 task 共享。
|
memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 task 共享。
|
||||||
**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免
|
**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免
|
||||||
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。
|
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。
|
||||||
本地 CLI = SENTINEL user;web/JWT 用 sub。SaaS 化时 `<storage_root>` 替换
|
user_id 由 web auth 入口(JWT `sub`)透传到 build_agent。SaaS 化时 `<storage_root>`
|
||||||
`workspace`,布局不变(§7.0)。
|
替换 `workspace`,布局不变(§7.0)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,28 +70,16 @@ class Session:
|
||||||
self.messages.append({"role": "system", "content": system_prompt})
|
self.messages.append({"role": "system", "content": system_prompt})
|
||||||
|
|
||||||
def append(self, msg: Any) -> None:
|
def append(self, msg: Any) -> None:
|
||||||
"""追加消息;非 system 落 DB,system 仅内存。"""
|
"""追加消息;非 system 落 DB,system 仅内存。
|
||||||
|
|
||||||
|
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` → `ensure_local_task_row`)写入;
|
||||||
|
Session 不再做 idempotent ensure(无 user 上下文,且 task 必先在,多余)。
|
||||||
|
"""
|
||||||
msg_dict = _to_dict(msg)
|
msg_dict = _to_dict(msg)
|
||||||
self.messages.append(msg_dict)
|
self.messages.append(msg_dict)
|
||||||
if msg_dict.get("role") == "system":
|
if msg_dict.get("role") == "system":
|
||||||
return
|
return
|
||||||
|
|
||||||
# 首次写入前,让 tasks 行就位。`ensure_local_task_row` 在 storage 层 idempotent。
|
|
||||||
# meta 字段(name/working_dir/skill/description/reasoning_effort)走 INSERT 一次性带入,
|
|
||||||
# 避免首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。
|
|
||||||
# name 是 NOT NULL,build_agent 必须放进 meta(新建 / resume 都已就位)。
|
|
||||||
from .storage.utils import ensure_local_task_row
|
|
||||||
ensure_local_task_row(
|
|
||||||
task_id=self.task_id,
|
|
||||||
name=self.meta.get("name", ""),
|
|
||||||
working_dir=self.meta.get("working_dir", ""),
|
|
||||||
skill=self.meta.get("skill", ""),
|
|
||||||
description=self.meta.get("description", ""),
|
|
||||||
model=self.meta.get("model", ""),
|
|
||||||
model_profile=self.meta.get("model_profile", ""),
|
|
||||||
reasoning_effort=self.meta.get("reasoning_effort", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
s.add(Message(
|
s.add(Message(
|
||||||
task_id=self.task_id,
|
task_id=self.task_id,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
"""§7 B 阶段:Storage 落 PG。
|
"""§7 B 阶段:Storage 落 PG。
|
||||||
|
|
||||||
入口:
|
入口:
|
||||||
from core.storage import get_engine, session_scope, ensure_local_sentinel
|
from core.storage import get_engine, session_scope
|
||||||
from core.storage.models import User, Task, Message
|
from core.storage.models import User, Task, Message
|
||||||
|
|
||||||
ZCBOT_DB_URL 环境变量必填(本地连测试 / staging PG;SaaS 连生产 PG)。
|
ZCBOT_DB_URL 环境变量必填(本地连测试 / staging PG;SaaS 连生产 PG)。
|
||||||
未设置时 get_engine() 抛 RuntimeError 并指引设置。
|
未设置时 get_engine() 抛 RuntimeError 并指引设置。
|
||||||
|
users 行由 web auth 入口按需 INSERT,storage 层不再 bootstrap 任何固定 user。
|
||||||
"""
|
"""
|
||||||
from .engine import (
|
from .engine import (
|
||||||
ensure_local_sentinel,
|
|
||||||
get_engine,
|
get_engine,
|
||||||
session_scope,
|
session_scope,
|
||||||
)
|
)
|
||||||
from .models import SENTINEL_USER_ID
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
NoSubtaskError,
|
NoSubtaskError,
|
||||||
check_no_subtask,
|
check_no_subtask,
|
||||||
|
|
@ -24,9 +23,7 @@ from .utils import (
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NoSubtaskError",
|
"NoSubtaskError",
|
||||||
"SENTINEL_USER_ID",
|
|
||||||
"check_no_subtask",
|
"check_no_subtask",
|
||||||
"ensure_local_sentinel",
|
|
||||||
"ensure_local_task_row",
|
"ensure_local_task_row",
|
||||||
"get_engine",
|
"get_engine",
|
||||||
"get_task",
|
"get_task",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""PG 连接 + Session factory + 本地 sentinel 初始化。
|
"""PG 连接 + Session factory。
|
||||||
|
|
||||||
`ZCBOT_DB_URL` 必填,标准 SQLAlchemy URL,如:
|
`ZCBOT_DB_URL` 必填,标准 SQLAlchemy URL,如:
|
||||||
postgresql+psycopg://user:pass@host:5432/zcbot
|
postgresql+psycopg://user:pass@host:5432/zcbot
|
||||||
|
|
||||||
未设置时 get_engine() 抛 RuntimeError 并打印指引(不引导 docker)。
|
未设置时 get_engine() 抛 RuntimeError 并打印指引(不引导 docker)。
|
||||||
|
users 行由 web auth 入口按需 INSERT (`web.auth.ensure_user_row`),引擎层不再 bootstrap。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -11,11 +12,9 @@ import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Iterator, Optional
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
from sqlalchemy import Engine, create_engine, select
|
from sqlalchemy import Engine, create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from .models import SENTINEL_USER_ID, User
|
|
||||||
|
|
||||||
_engine: Optional[Engine] = None
|
_engine: Optional[Engine] = None
|
||||||
_SessionLocal: Optional[sessionmaker[Session]] = None
|
_SessionLocal: Optional[sessionmaker[Session]] = None
|
||||||
|
|
||||||
|
|
@ -64,17 +63,3 @@ def session_scope() -> Iterator[Session]:
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def ensure_local_sentinel() -> None:
|
|
||||||
"""本地形态:若 users 表无 sentinel 行则 INSERT。
|
|
||||||
|
|
||||||
本地 CLI 启动时调用一次,SaaS 形态不调用(用户由 auth 流程创建)。
|
|
||||||
幂等。
|
|
||||||
"""
|
|
||||||
with session_scope() as s:
|
|
||||||
existing = s.execute(
|
|
||||||
select(User).where(User.user_id == SENTINEL_USER_ID)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if existing is None:
|
|
||||||
s.add(User(user_id=SENTINEL_USER_ID))
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
|
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
|
||||||
|
|
||||||
3 张表:users / tasks / messages。
|
4 张表:users / tasks / messages / invites。
|
||||||
- users 本地形态固定 INSERT sentinel(`00000000-...`)
|
- users 行在 web 入口(`/v1/auth/login*`)按需 INSERT;user_id 由 invite name uuid5 推导 或 platform 直传
|
||||||
- messages.payload 用 jsonb,GIN 索引在 migration 里建
|
- messages.payload 用 jsonb,GIN 索引在 migration 里建
|
||||||
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs 表);
|
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs 表);
|
||||||
原 runs / usage_events 表 0004 删 — 详 DESIGN §7.4 取舍 / PROGRESS 05-18
|
原 runs / usage_events 表 0004 删 — 详 DESIGN §7.4 取舍 / PROGRESS 05-18
|
||||||
|
- invites 0005 加(token PK / name UNIQUE / created_at):dev SPA 邀请码登录后端
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -30,10 +31,6 @@ class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 本地单用户 sentinel —— 所有本地 task 都 FK 到这一行
|
|
||||||
SENTINEL_USER_ID: UUID = UUID("00000000-0000-0000-0000-000000000000")
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
|
@ -97,3 +94,19 @@ class Message(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(Base):
|
||||||
|
"""dev SPA 邀请码登录后端表(0005)。
|
||||||
|
|
||||||
|
token 是 lookup 入口(PK);name 是显示名 + uuid5(NS, name) → user_id 推导源
|
||||||
|
(UNIQUE 防同名 = 两 token 共身份);created_at 给审计。
|
||||||
|
管理:目前直接在 DB 里 INSERT/DELETE(`python main.py invite ...` 后续若需要再加)。
|
||||||
|
"""
|
||||||
|
__tablename__ = "invites"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
from .engine import session_scope
|
from .engine import session_scope
|
||||||
from .models import SENTINEL_USER_ID, Task
|
from .models import Task
|
||||||
|
|
||||||
|
|
||||||
class NoSubtaskError(ValueError):
|
class NoSubtaskError(ValueError):
|
||||||
|
|
@ -18,13 +18,13 @@ class NoSubtaskError(ValueError):
|
||||||
def ensure_local_task_row(
|
def ensure_local_task_row(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
name: str,
|
name: str,
|
||||||
|
user_id: UUID,
|
||||||
working_dir: str = "",
|
working_dir: str = "",
|
||||||
skill: str = "",
|
skill: str = "",
|
||||||
description: str = "",
|
description: str = "",
|
||||||
model: str = "",
|
model: str = "",
|
||||||
model_profile: str = "",
|
model_profile: str = "",
|
||||||
reasoning_effort: str = "",
|
reasoning_effort: str = "",
|
||||||
user_id: UUID = SENTINEL_USER_ID,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
|
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ def ensure_local_task_row(
|
||||||
def upsert_task(
|
def upsert_task(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
*,
|
*,
|
||||||
user_id: UUID = SENTINEL_USER_ID,
|
user_id: UUID,
|
||||||
**fields: Any,
|
**fields: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。
|
"""INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。
|
||||||
|
|
@ -105,7 +105,7 @@ def get_task(task_id: UUID) -> Optional[Task]:
|
||||||
|
|
||||||
def check_no_subtask(
|
def check_no_subtask(
|
||||||
working_dir: str,
|
working_dir: str,
|
||||||
user_id: UUID = SENTINEL_USER_ID,
|
user_id: UUID,
|
||||||
exclude_task_ids: Optional[Iterable[UUID]] = None,
|
exclude_task_ids: Optional[Iterable[UUID]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。
|
"""§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ def _iso(dt: Optional[datetime]) -> str:
|
||||||
@dataclass
|
@dataclass
|
||||||
class TaskState:
|
class TaskState:
|
||||||
task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID)
|
task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID)
|
||||||
|
user_id: UUID # 归属 user,UPSERT INSERT 路径必填(列 NOT NULL)
|
||||||
name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读)
|
name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读)
|
||||||
working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑)
|
working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑)
|
||||||
skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表)
|
skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表)
|
||||||
|
|
@ -47,6 +48,7 @@ class TaskState:
|
||||||
"""UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。"""
|
"""UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。"""
|
||||||
upsert_task(
|
upsert_task(
|
||||||
UUID(self.task_id),
|
UUID(self.task_id),
|
||||||
|
user_id=self.user_id,
|
||||||
name=self.name,
|
name=self.name,
|
||||||
working_dir=self.working_dir,
|
working_dir=self.working_dir,
|
||||||
skill=self.skill,
|
skill=self.skill,
|
||||||
|
|
@ -63,6 +65,7 @@ class TaskState:
|
||||||
def from_row(cls, row: TaskRow) -> "TaskState":
|
def from_row(cls, row: TaskRow) -> "TaskState":
|
||||||
return cls(
|
return cls(
|
||||||
task_id=str(row.task_id),
|
task_id=str(row.task_id),
|
||||||
|
user_id=row.user_id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
working_dir=row.working_dir,
|
working_dir=row.working_dir,
|
||||||
skill=row.skill,
|
skill=row.skill,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ DESIGN.md section 7.4 schema. First migration.
|
||||||
older versions need the extension).
|
older versions need the extension).
|
||||||
- messages.payload GIN index (jsonb_path_ops).
|
- messages.payload GIN index (jsonb_path_ops).
|
||||||
- tasks (user_id, task_dir) and (user_id, status) composite indexes.
|
- tasks (user_id, task_dir) and (user_id, status) composite indexes.
|
||||||
- Local sentinel user is INSERTed by core.storage.ensure_local_sentinel
|
- users rows are INSERTed on demand by web auth entry points
|
||||||
at CLI startup, NOT in this migration (avoids stray sentinel rows on
|
(`web.auth.ensure_user_row`), NOT in this migration.
|
||||||
the SaaS instance).
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""add invites table — dev SPA 邀请码登录改 DB 后端。
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-05-19
|
||||||
|
|
||||||
|
接 05-19 邀请码登录(`/v1/auth/login_invite`):原 `ZCBOT_INVITES` env 字符串解析撤,
|
||||||
|
改 `invites` 表。最薄 schema:
|
||||||
|
- token PK — login 入口直接查这列
|
||||||
|
- name UNIQUE — 推导 uuid5 用,同 name = 同 user_id,UNIQUE 防"两 token 同身份"漏
|
||||||
|
- created_at — 审计
|
||||||
|
|
||||||
|
不存 user_id(由 uuid5(NS, name) 推导,namespace 固定不动);不存 revoked_at
|
||||||
|
(撤销直接 DELETE,5 人级别用户不要软删的额外分支)。
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0005"
|
||||||
|
down_revision: Union[str, None] = "0004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invites",
|
||||||
|
sa.Column("token", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("invites")
|
||||||
30
web/app.py
30
web/app.py
|
|
@ -38,7 +38,7 @@ from core.storage import (
|
||||||
from core.storage.models import Message, Task
|
from core.storage.models import Message, Task
|
||||||
from core.storage.utils import ensure_local_task_row
|
from core.storage.utils import ensure_local_task_row
|
||||||
|
|
||||||
from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token
|
from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_invite
|
||||||
from .broker import broker
|
from .broker import broker
|
||||||
from .sinks import WebEventSink
|
from .sinks import WebEventSink
|
||||||
|
|
||||||
|
|
@ -267,6 +267,10 @@ class LoginRequest(BaseModel):
|
||||||
platform_key: str
|
platform_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class InviteLoginRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── App 工厂 ──────────────────────
|
# ────────────────────── App 工厂 ──────────────────────
|
||||||
|
|
||||||
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
||||||
|
|
@ -342,6 +346,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
platform_key 错 → 403;user_id 非 UUID → 400。
|
platform_key 错 → 403;user_id 非 UUID → 400。
|
||||||
user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。
|
user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。
|
||||||
|
platform 服务端用此入口注入指定 user_id;dev SPA 走 /login_invite。
|
||||||
"""
|
"""
|
||||||
if body.platform_key != auth_cfg.platform_key:
|
if body.platform_key != auth_cfg.platform_key:
|
||||||
raise HTTPException(403, "invalid platform_key")
|
raise HTTPException(403, "invalid platform_key")
|
||||||
|
|
@ -358,6 +363,29 @@ def create_app() -> FastAPI:
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.post("/v1/auth/login_invite", tags=["auth"])
|
||||||
|
def login_invite(body: InviteLoginRequest):
|
||||||
|
"""邀请码登录(dev SPA 给同事试用)。
|
||||||
|
|
||||||
|
- token 在 `invites` 表(0005)未命中 → 403(包括表为空 / 已 DELETE 的情况)
|
||||||
|
- 命中 → 由 name 推导 user_id (uuid5),幂等建 users 行,签 JWT
|
||||||
|
- 发码:DB 里 `INSERT INTO invites(token, name) VALUES('xxx','alice')`;
|
||||||
|
撤销:`DELETE FROM invites WHERE name='alice'`
|
||||||
|
"""
|
||||||
|
hit = resolve_invite(body.token)
|
||||||
|
if hit is None:
|
||||||
|
raise HTTPException(403, "invalid invite token")
|
||||||
|
name, uid = hit
|
||||||
|
ensure_user_row(uid)
|
||||||
|
token, exp = mint_token(auth_cfg, uid)
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
||||||
|
"user_id": str(uid),
|
||||||
|
"name": name,
|
||||||
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
# ───────────── Tasks CRUD ─────────────
|
# ───────────── Tasks CRUD ─────────────
|
||||||
|
|
||||||
@app.post("/v1/tasks", status_code=201, tags=["tasks"])
|
@app.post("/v1/tasks", status_code=201, tags=["tasks"])
|
||||||
|
|
|
||||||
52
web/auth.py
52
web/auth.py
|
|
@ -1,31 +1,39 @@
|
||||||
"""Auth: PLATFORM_KEY → JWT token 兑换(§7 D' 过渡形态)。
|
"""Auth: 两条 login 路径,签同款 JWT(§7 D' 过渡形态)。
|
||||||
|
|
||||||
模型:
|
模型:
|
||||||
- `PLATFORM_KEY` env(必填)是 platform/本仓库间的共享密钥;platform 服务端 / dev 页持有它
|
- `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥
|
||||||
- `JWT_SECRET` env(必填)用于 HS256 签 token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护
|
- `JWT_SECRET` env(必填):HS256 签 token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护
|
||||||
- `POST /v1/auth/login {user_id, platform_key}` → `{token, expires_at}`(后端校验 key 对 → 签 JWT)
|
- **`invites` 表**(0005)dev SPA 邀请码登录后端:token PK / name UNIQUE / created_at;
|
||||||
- 后续 `/v1/*`(除 /healthz、/docs、/openapi.json、/、/v1/auth/login)走 `Authorization: Bearer <jwt>`
|
user_id 由 `uuid5(_INVITE_NAMESPACE, name)` 推导,重启稳定;改 name 会换身份(数据看不到)。
|
||||||
|
表空 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`)
|
||||||
|
- `POST /v1/auth/login {user_id, platform_key}` → JWT(platform 服务端用,自带 user_id 注入)
|
||||||
|
- `POST /v1/auth/login_invite {token}` → JWT(dev SPA 用,name → user_id 服务端推导)
|
||||||
|
- 后续 `/v1/*`(除 /healthz、/docs、/openapi.json、/、/v1/auth/login*)走 `Authorization: Bearer <jwt>`
|
||||||
- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖,默 7 天
|
- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖,默 7 天
|
||||||
|
|
||||||
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key),路由层 Depends 不变。
|
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);invite 路径同期可下线。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid5
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from core.storage import session_scope
|
from core.storage import session_scope
|
||||||
from core.storage.models import SENTINEL_USER_ID, User
|
from core.storage.models import Invite, User
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
|
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
|
||||||
|
|
||||||
|
# uuid5 命名空间 — 别改,改了所有邀请码用户身份漂移、历史数据全丢
|
||||||
|
_INVITE_NAMESPACE = UUID("9b5e7a2a-3c8e-5f4d-8c1a-f0e6b9d7c3a1")
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig:
|
class AuthConfig:
|
||||||
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
|
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
|
||||||
|
|
@ -62,6 +70,24 @@ class AuthConfig:
|
||||||
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
|
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_invite(token: str) -> Optional[tuple[str, UUID]]:
|
||||||
|
"""查 invites 表;命中返 `(name, user_id)`,user_id 由 uuid5(NS, name) 推导。
|
||||||
|
|
||||||
|
返 None 表示 token 未命中(空串 / 不存在 / 已 DELETE 都走这条)。每次 login 一次
|
||||||
|
SELECT,5 人级别用户开销可忽略;不缓存避免 DELETE 后还能登的不一致窗口。
|
||||||
|
"""
|
||||||
|
t = (token or "").strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
with session_scope() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Invite.name).where(Invite.token == t)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return row, uuid5(_INVITE_NAMESPACE, row)
|
||||||
|
|
||||||
|
|
||||||
def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
|
def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
|
||||||
"""签 JWT。返回 `(token, exp_unix_seconds)`。"""
|
"""签 JWT。返回 `(token, exp_unix_seconds)`。"""
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
|
|
@ -89,9 +115,9 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID:
|
||||||
def ensure_user_row(user_id: UUID) -> None:
|
def ensure_user_row(user_id: UUID) -> None:
|
||||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
||||||
|
|
||||||
dev 用 SENTINEL,platform 注入的 user_id 也走这条 — 无论是新用户首次登录还是
|
邀请码登录(uuid5 推导)、platform_key 登录(显式传入)、未来 OIDC 都走这条 —
|
||||||
既有用户复登,都安全。真用户 profile(email/oidc_subject 等)在 D' OIDC 阶段
|
新用户首次登录建行,既有用户复登 no-op。真用户 profile(email/oidc_subject 等)
|
||||||
再走专门的 register/sync 路径。
|
在 D' OIDC 阶段再走专门的 register/sync 路径。
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
|
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
|
||||||
|
|
@ -130,9 +156,9 @@ def make_require_user(cfg: AuthConfig):
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthConfig",
|
"AuthConfig",
|
||||||
"SENTINEL_USER_ID",
|
|
||||||
"ensure_user_row",
|
"ensure_user_row",
|
||||||
"make_require_user",
|
"make_require_user",
|
||||||
"mint_token",
|
"mint_token",
|
||||||
|
"resolve_invite",
|
||||||
"verify_token",
|
"verify_token",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -332,16 +332,14 @@
|
||||||
<div id="login">
|
<div id="login">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>zcbot 登录</h2>
|
<h2>zcbot 登录</h2>
|
||||||
<label for="li-uid">user_id (UUID)</label>
|
<label for="li-token">邀请码</label>
|
||||||
<input id="li-uid" autocomplete="off" />
|
<input id="li-token" type="password" autocomplete="off" placeholder="请输入管理员分配的邀请码" />
|
||||||
<label for="li-key">platform_key</label>
|
|
||||||
<input id="li-key" type="password" autocomplete="off" />
|
|
||||||
<div class="err" id="li-err"></div>
|
<div class="err" id="li-err"></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" id="li-go">登录</button>
|
<button class="primary" id="li-go">登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="small muted" style="margin-top: 12px;">
|
<div class="small muted" style="margin-top: 12px;">
|
||||||
本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env <code>PLATFORM_KEY</code>。
|
邀请码由管理员在服务端 <code>invites</code> 表里分配,每人一码,丢失找管理员重发。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -475,13 +473,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SENTINEL = "00000000-0000-0000-0000-000000000000";
|
|
||||||
const LS_TOKEN = "zcbot.token";
|
const LS_TOKEN = "zcbot.token";
|
||||||
const LS_UID = "zcbot.user_id";
|
const LS_UID = "zcbot.user_id";
|
||||||
|
const LS_NAME = "zcbot.name";
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
token: localStorage.getItem(LS_TOKEN) || "",
|
token: localStorage.getItem(LS_TOKEN) || "",
|
||||||
userId: localStorage.getItem(LS_UID) || "",
|
userId: localStorage.getItem(LS_UID) || "",
|
||||||
|
userName: localStorage.getItem(LS_NAME) || "",
|
||||||
taskId: null,
|
taskId: null,
|
||||||
taskMeta: null,
|
taskMeta: null,
|
||||||
filesPath: "",
|
filesPath: "",
|
||||||
|
|
@ -611,24 +610,20 @@ function highlightIn(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── login ─────
|
// ───── login ─────
|
||||||
$("li-uid").value = state.userId || SENTINEL;
|
|
||||||
|
|
||||||
$("li-go").onclick = doLogin;
|
$("li-go").onclick = doLogin;
|
||||||
$("li-key").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
|
$("li-token").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
|
||||||
$("li-uid").addEventListener("keydown", (e) => { if (e.key === "Enter") $("li-key").focus(); });
|
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
const uid = $("li-uid").value.trim();
|
const token = $("li-token").value.trim();
|
||||||
const key = $("li-key").value;
|
|
||||||
$("li-err").textContent = "";
|
$("li-err").textContent = "";
|
||||||
if (!uid || !key) {
|
if (!token) {
|
||||||
$("li-err").textContent = "user_id 与 platform_key 都要填";
|
$("li-err").textContent = "请填邀请码";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/v1/auth/login", {
|
const r = await fetch("/v1/auth/login_invite", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ user_id: uid, platform_key: key }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
|
|
@ -637,8 +632,10 @@ async function doLogin() {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
state.token = data.token;
|
state.token = data.token;
|
||||||
state.userId = data.user_id;
|
state.userId = data.user_id;
|
||||||
|
state.userName = data.name || "";
|
||||||
localStorage.setItem(LS_TOKEN, state.token);
|
localStorage.setItem(LS_TOKEN, state.token);
|
||||||
localStorage.setItem(LS_UID, state.userId);
|
localStorage.setItem(LS_UID, state.userId);
|
||||||
|
if (state.userName) localStorage.setItem(LS_NAME, state.userName);
|
||||||
enterApp();
|
enterApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("li-err").textContent = e.message;
|
$("li-err").textContent = e.message;
|
||||||
|
|
@ -646,9 +643,10 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
state.token = ""; state.userId = "";
|
state.token = ""; state.userId = ""; state.userName = "";
|
||||||
localStorage.removeItem(LS_TOKEN);
|
localStorage.removeItem(LS_TOKEN);
|
||||||
localStorage.removeItem(LS_UID);
|
localStorage.removeItem(LS_UID);
|
||||||
|
localStorage.removeItem(LS_NAME);
|
||||||
if (state.evtSrc) state.evtSrc.close();
|
if (state.evtSrc) state.evtSrc.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
@ -658,7 +656,10 @@ $("hd-logout").onclick = logout;
|
||||||
function enterApp() {
|
function enterApp() {
|
||||||
$("login").style.display = "none";
|
$("login").style.display = "none";
|
||||||
$("app").classList.add("ready");
|
$("app").classList.add("ready");
|
||||||
$("hd-who").textContent = state.userId;
|
// 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid
|
||||||
|
const uid8 = (state.userId || "").slice(0, 8);
|
||||||
|
$("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId;
|
||||||
|
$("hd-who").title = state.userId;
|
||||||
loadTaskList();
|
loadTaskList();
|
||||||
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
|
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
|
||||||
}
|
}
|
||||||
|
|
@ -1649,8 +1650,7 @@ if (state.token) {
|
||||||
// 已有 token:试探一下,失败回登录页
|
// 已有 token:试探一下,失败回登录页
|
||||||
enterApp();
|
enterApp();
|
||||||
} else {
|
} else {
|
||||||
$("li-uid").value = SENTINEL;
|
$("li-token").focus();
|
||||||
$("li-uid").focus();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue