Files
codex-app-server/cloud/README.md
T

192 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 云端控制面(cloud/)
本目录是 host.py 本地编排之上的云端架构实施:控制面 server +
已就绪的模块(认证、队列、沙箱、记忆、知识库、模型路由、成本、
webhook)。零第三方依赖,只用 Python 标准库。
> 诚实边界:这是一个本地可跑的控制面演示,不是已建成的真实云服务。
> 本 server 不跑模型回合、不连真实 GitHub/Slack/云服务。生产前仍缺
> 的外部条件见文末“生产前仍缺什么”一节,不要绕过去。
## 架构:谁负责什么
```
客户端 / GitHub / Slack
│ HTTP + X-API-Key(webhook 靠签名,见下)
▼
cloud/server.py(控制面,ThreadingHTTPServer)
├─ 项目 / 任务:建、查、取消、心跳、stale 回收
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
│ (后续由 worker 接线)
▼
worker(尚未接成 HTTP 端点,本版需另行调用模块)
├─ 派发顺序:cloud/queue.py(order_tasks / ready_tasks /
│ budget_exceeded / TaskQueue;状态机与 host.py 对齐)
├─ 执行:cloud/sandbox.py(LocalExecutor 调 host.py CLI 做只读
│ 封装;DockerExecutor / CloudExecutor 仍是占位,未接通)
├─ 记忆:cloud/memory.py(MemoryStore:任务摘要,供 prompt 上下文)
├─ 知识库:cloud/kb.py(KBStore:按项目隔离的文档检索)
├─ 模型路由:cloud/models.py(ModelRouter / record_fallback:
│ 只决定 fallback,不跑模型回合)
├─ 成本:cloud/cost.py(CostStore / fetch_account_usage;
│ usage 来自调用方注入的 client,本模块不联网)
└─ 通知:cloud/webhooks.py(NotificationService:webhook + Slack
占位分发;Web Push / APNs / FCM 明确未接,需要证书才做)
```
状态库:单一 SQLite 文件。AuthStore 用 `users` / `project_members` /
`audit_log` 三张表;server 用 `projects` / `tasks` 两张表,表名不冲突。
同一个 db_path 传给两边即可。不要再用共享 JSON 文件当多用户状态库。
并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite
连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在
多线程 handler 间共用一个连接。注意:AuthStore 自身持有一个连接,
server 因此不在 handler 间共享 AuthStore 实例。
任务状态机(与 host.py 对齐):
```
queued → running → completed / failed / interrupted / merged / merge-failed
```
本版 server 只流转 queued → running(心跳)→ interrupted(取消 /
sweep 回收)。`completed` / `merged` 由 worker / 控制面后续调用方在
跑完模型回合、合完分支后回写,本 server 不提供“跑模型”端点,也不
要假装一次 POST 就能让任务变 completed。
## 启动方式
```bash
cd ~/workspace/codex-app-server
python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>]
# 默认:host 127.0.0.1,port 8765,db 为当前目录下的 cloud-control-plane.db
```
测试用(不自动 serve):
```python
from cloud.server import create_server
server = create_server(db_path, host="127.0.0.1", port=8765)
server.serve_forever()
```
用户与 API key 由调用方先用 AuthStore 建(明文 key 只返回一次,
库里只存 sha256 哈希,绝不把 key 写进文件或聊天记录):
```python
from cloud.auth import AuthStore
store = AuthStore(db_path)
user_id, api_key_plain = store.create_user("alice")
```
## API 列表
全部返回 JSON,`Content-Type: application/json`。除 `GET /health`
与 `/webhooks/*` 外,所有端点要求请求头 `X-API-Key`,无 key 或 key
错返回 `401 {"error": "unauthorized"}`。`/webhooks/*` 不走 X-API-Key,
因为外部服务无法持本站 API key,改靠各自的签名认证(见下)。
| 方法与路径 | 权限 | 说明 |
| --- | --- | --- |
| `GET /health` | 无需认证 | `200 {"ok": true, "service": "cloud-control-plane"}` |
| `POST /projects` | 认证即可 | body `{name, id?}`;无 id 则生成 uuid;调用者记为 owner(调 `AuthStore.create_project`,同时写 projects 表并记审计 `create_project`);返回 `201` + 项目 |
| `GET /projects` | 认证即可 | 只返回调用者是成员的项目:`{"projects": [...]}` |
| `POST /tasks` | `create_task` | body `{project_id, prompt, depends_on?, priority?, idempotency_key?}`;无权限 `403`;同 `project_id + idempotency_key` 已存在则返回已有任务(`200`,不建重复);否则建 `queued` 任务并记审计 `create_task`,返回 `201` |
| `GET /tasks?project_id=...` | 该项目 `read` | 返回该项目任务列表:`{"tasks": [...]}`;无权限 `403` |
| `GET /tasks/{id}` | 该任务所属项目 `read` | 无权限 `403`;不存在 `404` |
| `POST /tasks/{id}/cancel` | 该项目 `create_task` | `queued` / `running` 改为 `interrupted` 并记审计 `cancel_task`;已是终态(completed / failed / interrupted / merged / merge-failed)返回 `400`;不存在 `404` |
| `POST /tasks/{id}/heartbeat` | 该项目 `create_task` | 更新 `last_heartbeat = now`;状态为 `queued` 则改为 `running`;返回任务 |
| `POST /admin/sweep` | 认证即可 | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` |
| `POST /webhooks/github` | GitHub 签名 | 见“webhook 端点” |
| `POST /webhooks/slack` | Slack 签名 | 见“webhook 端点” |
单个项目 / 任务以裸对象返回;列表以 `{"projects": [...]}` /
`{"tasks": [...]}` 包装。任务对象字段:`id`、`project_id`、`prompt`、
`status`、`idempotency_key`、`depends_on`(list)、`priority`、
`created_by`、`created_at`、`updated_at`、`last_heartbeat`。
函数 `sweep_stale_tasks(db_path, threshold_seconds=300) -> list` 独立
可单测,不依赖 HTTP;回收动作的审计 `user_id` 记为 `"system"`。
### webhook 端点(加分项,已实现;只解析,不执行任务)
- `POST /webhooks/github`:用 `webhooks.verify_github_signature`
验签(请求头 `X-Hub-Signature-256`),secret 从环境变量
`GITHUB_WEBHOOK_SECRET` 读。未设 secret 返回 `503`;验签失败
返回 `401`;成功返回 `parse_github_pr_comment` 的结果(PR 评论含
`/fix-review` 时返回 fix_review 触发点,否则 `{"result": null}`)。
真正执行 fix-review 由控制面后续调 host.py 完成,本端点不执行。
- `POST /webhooks/slack`:用 `verify_slack_signature` 验签(请求头
`X-Slack-Signature` / `X-Slack-Request-Timestamp`,含 300 秒防重放),
secret 从环境变量 `SLACK_SIGNING_SECRET` 读。未设返回 `503`;
验签失败返回 `401`;成功返回 `parse_slack_command` 的结果,不执行任务。
## 权限表
角色高低:viewer < contributor < maintainer < owner。未加入项目一律
无权限。动作的最低角色(`AuthStore.check_permission` 的实际规则):
| 动作 | viewer | contributor | maintainer | owner |
| --- | --- | --- | --- | --- |
| `read`(读项目任务) | ✅ | ✅ | ✅ | ✅ |
| `create_task`(建任务 / 取消 / 心跳) | ❌ | ✅ | ✅ | ✅ |
| `approve_merge`(批合并,worker 侧用) | ❌ | ❌ | ✅ | ✅ |
| `manage_project`(仅 owner,改成员等) | ❌ | ❌ | ❌ | ✅ |
说明:`manage_project` 的最低角色是 owner 本人,不是 maintainer;
改成员(`AuthStore.set_member`)只有 owner 能做,否则抛
`PermissionError`。审计(`audit` / `list_audit`)只追加、只读,本模块
不提供删除或修改审计记录的方法。
注意一个实际签名细节:`AuthStore.create_project(project_id,
owner_user_id)` 只写 `project_members` 的 owner 成员记录,不建项目
记录本身;项目记录(name 等)由 server 写 `projects` 表。两者配合
才算一个完整项目,不要只调一处。
## 测试
```bash
cd ~/workspace/codex-app-server
python3 -m unittest discover -s cloud/tests -v # 模块单测 + 集成单测
python3 -m unittest discover -s tests -v # 原有 host.py 测试
```
`cloud/tests/test_server.py` 用 http.client 对本地临时端口的 server
做真实 HTTP 测试(临时 SQLite 文件与临时端口,跑完关闭 server),
覆盖:无 key / 错误 key 401、非成员读项目 403、viewer 建任务 403、
幂等键第二次返回同一任务且任务数不增加、cancel 把 running 任务变
interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳的
任务、sweep 扫掉心跳过期(手动改旧 last_heartbeat 或 threshold=0)
的 running 任务、GET /health 不认证也通、GET /projects 只返回成员项目,
以及两个 webhook 端点的 503 / 401 / 验签成功解析。
## 生产前仍缺什么
以下每一项都是外部条件,缺了就不能算生产可用,不要绕过去:
- **常驻机器 / 托管服务**:本 VM 会被平台定时回收,跑在这里只能算
演示。生产需要一台常驻机器或托管服务(以及相应的进程守护与
重启策略),否则控制面会随 VM 回收一起消失。
- **GitHub 远端 + PAT**:codex-app-server 目前仍无 git 远端,`gh`
也未登录;真实 PR 流程(host.py 的 `pr` / `pr-checks` / `pr-merge`)
需要先建远端并用 Secure Vault 供给 PAT,PAT 绝不写文件。
- **Slack App**:需要真实 Slack App(签名密钥、斜杠命令、事件订阅)
才能接通 `SLACK_SIGNING_SECRET` 背后的入站;目前只有验签与解析,
且 `NotificationService` 的 Slack 发送端仍是占位注入。
- **云容器账号**:`CloudExecutor` 仍是占位(无账号即抛错),Docker
执行也未接通容器挂载 / 网络隔离 / 资源限额;接通前不要宣称任务
已在云端或容器里跑。
- **模型 key 供给方式**:这是当前最实在的卡点。上次
real-nightly-tests automation 就卡在这里:定时进程手里没有中转站
key,key 从不存文件、只在进程环境里 transient 供给。控制面要跑
真实模型回合,必须先由用户定下供给方式(例如常驻进程的环境、
Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key
写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker
的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
- **其它待接线(非外部条件,但同样未完成)**:memory / KB / 成本 /
模型路由尚未接成 HTTP 端点;OAuth(GitHub/Google)未接,首版只有
API key + 哈希认证;Web Push / APNs / FCM 推送未接,需要证书才做;
单机 SQLite 在高并发写下仍有 busy 锁上限,生产量上来后需评估
换库或单写者队列。