云端控制面(cloud/)
本目录是 host.py 本地编排之上的云端架构实施:控制面 server + 已就绪的模块(认证、队列、沙箱、记忆、知识库、模型路由、成本、 webhook)。零第三方依赖,只用 Python 标准库。
诚实边界:这是一个本地可跑的控制面演示,不是已建成的真实云服务。 本 server 不跑模型回合、不连真实 GitHub/Slack/云服务。生产前仍缺 的外部条件见文末“生产前仍缺什么”一节,不要绕过去。
架构:谁负责什么
客户端 / GitHub / Slack
│ HTTP + X-API-Key(webhook 靠签名,OAuth 靠 state,见下)
▼
cloud/server.py(控制面,ThreadingHTTPServer)
├─ 项目 / 任务:建、查、取消、心跳、stale 回收、任务成本
├─ 记忆 / 知识库 / 成本 / 模型:已接成 HTTP 端点(见 API 表)
├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架)
├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤)
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
├─ 数据库接口:cloud/db.py(Database 接口 + SQLite 实现,
│ WAL + busy_timeout + 有限重试;Postgres 只是骨架,见下)
├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试)
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
状态库:单一 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/*、/oauth/* 外,所有端点要求请求头 X-API-Key,无 key
或 key 错返回 401 {"error": "unauthorized"}。/webhooks/* 不走
X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认证。
/oauth/* 不走 X-API-Key,因为 OAuth 登录的起点就是用户还没有
本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加
client_secret(只走环境变量),不是无认证后门。
OAuth 接线步骤(脚手架,已实现,未接真实应用)
- 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id,
回调地址填本站的
/oauth/{provider}/callback公网地址。 GET /oauth/{provider}/login?client_id=...&redirect_uri=...拿 authorization_url,把用户送到 provider 授权页。- provider 回调本站并带回 code + state,调
POST /oauth/{provider}/callback,验 state → 换 token → 映射 本站用户并记审计。client_secret 只从环境变量读 (GITHUB_OAUTH_CLIENT_SECRET/GOOGLE_OAUTH_CLIENT_SECRET), 绝不写文件、绝不进 URL、换 token 前未配好就明确报错。 - 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程 (目前是确定性用户名新建/复用,不做账号绑定)。
推送凭证清单
- webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有 sender 时明确报错,不静默成功。
- webpush:需要 VAPID 密钥对(私钥绝不进仓库)。
- apns:需要 APNs 证书。
- fcm:需要 FCM key。 以上三者目前只有接口 + 缺凭证报错,没有真实发送端。
SQLite 并发上限
server 的连接统一走 cloud/db.py:WAL 模式、busy_timeout 5000ms、
写锁冲突有限重试 3 次(退避 0.05s/0.1s/0.2s,重试耗尽抛原始错误,
绝不无限循环)。多线程并发写测试已覆盖。但这只是单机缓解:
写仍是单写者串行,写密集或跨机器时必须换 Postgres,这是下一阶段,
不是本轮假装解决。
数据库后端选择(接口层)
cloud/db.py 已抽成接口层,不改旧调用:
Database接口的方法就是原有 SQLite 实现对外提供的全部方法, 按实际代码为准:connect(连接 + WAL 设置)、execute_with_retry(执行)、read_all(查询)、transaction(事务)、is_locked_error(锁冲突判定)。SQLiteDatabase:现有实现改名后的类封装,原样保留 WAL / busy_timeout / 有界重试;模块级函数connect/execute_with_retry/read_all/transaction/is_locked_error仍可用,行为不变。PostgresDatabase:骨架,不是可用后端。即使驱动已装,其connect/ 执行 / 查询 / 事务方法仍抛NotImplementedError。- 工厂
create_database(backend, db_path)/database_from_env(): 两个后端提供同一组接口方法与签名,见cloud/tests/test_db_backend.py的接口契约测试。
后端选择环境变量(server 启动时生效,默认仍是 SQLite):
| 环境变量 | 说明 | 默认 |
|---|---|---|
CLOUD_DB_BACKEND |
sqlite 或 postgres(postgresql 同义);其它值启动即报 ValueError,不静默当 sqlite |
sqlite |
CLOUD_DB_HOST |
Postgres 主机,全部走环境变量,不硬编码地址 | 无(未设即 None) |
CLOUD_DB_PORT |
Postgres 端口,须为整数 | 无(未设即 None,走驱动默认) |
CLOUD_DB_NAME |
Postgres 库名 | 无 |
CLOUD_DB_USER |
Postgres 用户名 | 无 |
CLOUD_DB_PASSWORD |
Postgres 密码,只走环境变量,绝不写文件、绝不进日志与 repr |
无 |
选 postgres 且驱动缺失时启动即明确报错:PostgresDatabase
初始化时探测 psycopg(优先)/ psycopg2,两者都缺就抛
PostgresDriverMissingError(RuntimeError 子类,文案含
psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
也绝不假装已接通 Postgres。create_server 在建表之前就做这
一步校验,所以失败时不会先建出一个 SQLite 库假装可用。
高并发换 Postgres 是下一阶段的运维项,不是本轮代码一改就 自动变生产。接口层 + 骨架只解决了“以后能换”,真正上生产还 需要:真实 Postgres 部署(含备份、连接数与权限)、安装 psycopg 驱动、写迁移脚本并把现有 SQLite 表结构与数据迁过去、 把 server / AuthStore / MemoryStore / KBStore / CostStore 等 目前仍直连 SQLite 的调用点逐一接到新后端并重测。在这些完成 之前,即使把
CLOUD_DB_BACKEND设成postgres,server 也只会 在启动时明确报错,不会跑起来——这是有意为之的诚实边界。
| 方法与路径 | 权限 | 说明 |
|---|---|---|
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 /tasks/{id}/cost |
该任务所属项目 read |
成本聚合:{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens};真数与估算分开;不存在 404,越权 403 |
POST /projects/{id}/memory |
该项目 create_task |
写线程摘要 body {thread_id?, goal, conclusion, files_changed?, open_issues?},返回 201 {id},并记审计 create_memory |
GET /projects/{id}/memory?top_k=5 |
该项目 read |
最近 active 摘要(默认 top_k=5)+ prompt_context(只含 top_k,不全量注入);不存在 404,越权 403 |
POST /projects/{id}/memory/{mid}/supersede |
该项目 create_task |
把摘要标废弃(deprecated,不是物理删除),并记审计 supersede_memory;摘要不属于该项目则 404 |
POST /projects/{id}/kb/index |
该项目 create_task |
索引文档 body {path, content, kind?},同项目同 path 重复索引会替换旧文档,返回 201 {id} |
GET /projects/{id}/kb/search?q=&top_k=5 |
该项目 read |
FTS + LIKE 回退,仍按项目过滤,跨项目文档绝不返回;q 缺失 400 |
GET /projects/{id}/cost |
该项目 read |
项目成本总览 + 按天:?day=YYYY-MM-DD 查单天,?days=N 查最近 N 天;无参数只给总览 |
GET /models |
认证即可 | 优先级列表(环境变量 MODELS_PRIORITY 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(fallback_count > 0 的任务);不返回 prompt 内容 |
GET /oauth/{provider}/login?client_id=&redirect_uri=&scope= |
无需 X-API-Key(pre-auth) | 返回 {provider, state, authorization_url},只返回 URL 不跳转;provider 仅 github/google,未知 400;state 已入库,10 分钟过期 |
POST /oauth/{provider}/callback |
无需 X-API-Key(pre-auth) | body {code, state, client_id, redirect_uri};验 state → 换 token → 映射到本站用户(复用 users 表,用户名 oauth:<provider>:<login>)并记审计 oauth_login;state 无效/过期、换 token 失败、无 secret 一律 400 且不建会话;成功不返回 API key(发放方式由调用方另行决定) |
POST /projects/{id}/push/subscriptions |
该项目 create_task |
记订阅 body {channel, target, preference?},channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 201 |
GET /projects/{id}/push/subscriptions |
该项目 read |
只列调用者自己的订阅(不泄露他人 target) |
DELETE /projects/{id}/push/subscriptions/{sid} |
该项目 create_task |
只能删自己的订阅,项目 owner 可删任意;不存在 404,他人订阅 403 |
POST /admin/push/dispatch |
该项目 read |
body {event_type, project_id, payload?};只发状态变化事件(completed/failed/goal-unmet/merge-failed/review-requested);HTTP 层无注入 sender:webhook/slack 报“无 sender”,webpush/apns/fcm 报缺凭证,都不静默成功;真实发送端由 worker 注入 sender 后调 push.dispatch_event |
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 的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
- 其它待接线(非外部条件,但同样未完成):OAuth 已接成脚手架 端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、 已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下 仍有上限,生产量上来后需换 Postgres(接口层与骨架已就绪, 但真实部署、驱动安装与迁移脚本仍缺,见上文“数据库后端选择”); worker 执行、云端执行、真实通知与真实模型回合均未接通。