云端控制面(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。
启动方式
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):
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 写进文件或聊天记录):
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 表。两者配合
才算一个完整项目,不要只调一处。
测试
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 锁上限,生产量上来后需评估 换库或单写者队列。