Files
codex-app-server/cloud/README.md
T
host dc3f572b2b Postgres real backend: psycopg adapter, schema, migration
Replace the PostgresDatabase skeleton with a real implementation:
psycopg (v3) first, psycopg2 second, lazy load, missing driver
still raises PostgresDriverMissingError with no silent SQLite
fallback. A sqlite3-style adapter (PgConnection/PgCursor/PgRow)
translates ? placeholders to %s outside string literals, maps
UNIQUE/FK violations, lock errors and connection failures to
explicit Postgres error types, and handles INSERT OR REPLACE,
PRAGMA table_info, AUTOINCREMENT and FTS5 refusal. POSTGRES_SCHEMA
covers all business tables with tasks result columns; server
init_db uses it under CLOUD_DB_BACKEND=postgres, SQLite path
unchanged. migrate_sqlite_to_postgres.py copies rows with
ON CONFLICT DO NOTHING and reseeds identity sequences.
Tests: cloud 239 (235 pass, 4 skipped without a real server),
host 27 pass; real-server tests skip unless CLOUD_TEST_PG_DSN
is set.
2026-10-26 03:23:06 +08:00

424 lines
27 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 靠签名,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(见下表 `POST /tasks/{id}/merge`)。
`completed` 由 worker 在跑完模型回合后回写;`merged` 只由合并
状态接口同步,不执行合并本身——合并仍由 host.py merge(本地
或 Docker 容器内)执行,成功后再调本接口,云端不该在任务
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/*`、`/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 接线步骤(脚手架,已实现,未接真实应用)
1. 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id,
回调地址填本站的 `/oauth/{provider}/callback` 公网地址。
2. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` 拿
authorization_url,把用户送到 provider 授权页。
3. provider 回调本站并带回 code + state,调
`POST /oauth/{provider}/callback`,验 state → 换 token → 映射
本站用户并记审计。client_secret 只从环境变量读
(`GITHUB_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_CLIENT_SECRET`),
绝不写文件、绝不进 URL、换 token 前未配好就明确报错。
4. 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程
(目前是确定性用户名新建/复用,不做账号绑定)。
### 推送凭证清单
- 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`:真实 Postgres 实现。调用方仍写 sqlite3
风格 SQL(`?` 占位符、`row["col"]`、`conn.execute` /
`fetchall` / `commit` / `rollback` / `close`),由
`PgConnection` / `PgCursor` / `PgRow` 适配层翻译成 Postgres
方言(`?` -> `%s`,字符串字面量里的 `?` 不动;
`INSERT OR REPLACE` -> `ON CONFLICT DO UPDATE`;
`PRAGMA table_info` -> information_schema;
`AUTOINCREMENT` -> `GENERATED BY DEFAULT AS IDENTITY`;
FTS5 虚拟表在 PG 上明确报错、KBStore 走 LIKE 分支)。
驱动异常映射成 `PostgresIntegrityError` /
`PostgresOperationalError` / `PostgresConnectionError`,
绝不把驱动异常原样漏成 sqlite3 异常。模块级 `connect()`
按 `CLOUD_DB_BACKEND` 分发,因此 server / AuthStore /
MemoryStore / KBStore / CostStore 等沿用 `connect()` 的
调用点无需重构即可走 Postgres;但 `backup.py` 的 SQLite
在线备份 API 仍只适用于 SQLite,Postgres 备份请用 pg_dump。
- 工厂 `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 部署步骤(不要只改一个环境变量)
改 `CLOUD_DB_BACKEND=postgres` 本身不会迁移任何数据,也不会
建库——只改它、数据还在旧 SQLite 文件里,新库是空的。完整
步骤是:
1. 装驱动(psycopg v3 优先):
```bash
pip install "psycopg[binary]"
# 其次才用 psycopg2:pip install psycopg2-binary
```
2. 在 Postgres 里建库与用户(示例库名与用户名请按实际部署改,
密码绝不写进仓库文件):
```sql
CREATE DATABASE cloud_control_plane;
CREATE USER cloud_app WITH PASSWORD '<仅在此处输入>';
GRANT ALL PRIVILEGES ON DATABASE cloud_control_plane TO cloud_app;
```
注意:表由应用启动时 `init_postgres_schema` 建(
`cloud/db.py` 的 `POSTGRES_SCHEMA`,与 SQLite 表名/列名
一一对应,含 tasks 的 6 个结果列;KB 在 PG 上无 FTS 表,
走 LIKE 搜索)。若数据库用户不是库 owner,另需对
public schema 授权(`GRANT ALL ON SCHEMA public TO cloud_app`),
否则建表会报权限错——这是部署侧问题,不是代码 bug。
3. 设环境变量并启动(`--db` 参数在 PG 模式下被忽略,连接
参数只走环境变量;`--db-backend postgres` 与环境变量等价):
```bash
export CLOUD_DB_BACKEND=postgres
export CLOUD_DB_HOST=127.0.0.1 CLOUD_DB_PORT=5432
export CLOUD_DB_NAME=cloud_control_plane
export CLOUD_DB_USER=cloud_app
export CLOUD_DB_PASSWORD='<仅在此处输入>'
python3 -m cloud.server --host 127.0.0.1 --port 8765
```
4. 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次
不重复插,第二次各表 inserted 应为 0):
```bash
python3 -m cloud.migrate_sqlite_to_postgres \
--sqlite /path/to/cloud-control-plane.db
# 输出示例:projects: 3 / tasks: 12 / ... / total inserted: N
# (只打印表名与行数,不打印任何密码或密钥)
```
迁移脚本逻辑在 `cloud/migrations.py`(
`migrate_sqlite_to_postgres`),身份列的序列会在迁完后
拨到当前最大 id,避免后续自动生成撞主键。
5. 回退到 SQLite:把 `CLOUD_DB_BACKEND` 改回 `sqlite`(或
删掉它,默认即 sqlite),并把 `--db` 指回原来的 SQLite
文件即可。但注意:切到 Postgres 之后新写的数据只在
Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据
导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮
未提供,不要假装存在)。
6. 备份:Postgres 侧用 `pg_dump`,不要用 `cloud/backup.py`
(那是 SQLite 在线备份 API,只适用于 SQLite 文件)。
> 诚实边界(仍需在真实服务器上验证,见下文“未验证清单”):
> 本机 VM 既无 psycopg 驱动、也无 Postgres 服务器,因此真实
> PG 路径在本机只能 skip(见测试一节),不能谎称已在真实
> PG 上跑通过。已验证的是:适配层翻译逻辑(fake 驱动)、
> schema 语句覆盖、SQLite 全量回归不坏。
| 方法与路径 | 权限 | 说明 |
| --- | --- | --- |
| `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}/merge` | 该项目 `approve_merge`(maintainer 以上) | 只同步状态,不执行合并:仅 `completed` 可改为 `merged` 并记审计 `merge_task`,其它状态(含已 merged 的重复调用)返回 `409`;不存在 `404`,越权 `403`,无 key `401` |
| `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` 表。两者配合
才算一个完整项目,不要只调一处。
## 测试
```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 执行已接通(见下文“Docker 沙箱执行器”),但真实
docker run、镜像构建、资源限额实测仍需在外部主机上做:
本 VM 没有 Docker,本轮未连外部主机、未向真实外部服务发
请求、未跑真实模型回合,不要宣称 Docker 沙箱已在真实环境验证。
- **模型 key 供给方式**:这是当前最实在的卡点。上次
real-nightly-tests automation 就卡在这里:定时进程手里没有中转站
key,key 从不存文件、只在进程环境里 transient 供给。控制面要跑
真实模型回合,必须先由用户定下供给方式(例如常驻进程的环境、
Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key
写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker
的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
- **其它待接线(非外部条件,但同样未完成)**:OAuth 已接成脚手架
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
仍有上限,生产量上来后需换 Postgres(Postgres 后端已接通,
但真实服务器部署与验证仍缺,见上文“数据库后端选择”);
worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、
真实通知与真实模型回合仍受 key 供给与云账号卡点限制。
## worker 常驻与真取消(本轮新增)
- 单次:`python3 -m cloud.worker --db <库> --host-py ../host.py`;
常驻:`python3 -m cloud.worker --db <库> --loop --interval 300`,
每轮 run_all,SIGTERM/SIGINT 时优雅退出,正在跑的任务先标
interrupted 再退出;CLI 起 worker 前先 init_db,全新库不再因
缺表而炸,循环内一轮异常记日志后继续,不带走整个 worker。
- 真取消:子进程以进程组启动(start_new_session),取消时先
SIGTERM 给整个进程组、等 2 秒、仍活着再 SIGKILL,杀完任务
保持 interrupted,执行结果不覆盖它;取消竞态(run_one 已先
标 interrupted)时 cancel 仍返回 True,因为中断确由本次取消触发。
- 任务结果列:tasks 表新增 result_text、error_text、actual_model、
started_at、finished_at、child_pid,init_db 用 PRAGMA 检查后
ALTER TABLE 幂等迁移,老库升级不丢数据;GET /tasks/{id} 返回
它们,缺 key 任务的 error_text 含 Missing environment variable。
## 可观测性与备份(本轮新增)
- 结构化日志:cloud/logging_config.py,JSON 行日志(时间、级别、
服务、任务 id、事件),worker 的认领/完成/失败/取消/fallback/
缺 key 记事件日志,绝不写密钥与环境变量值。
- 指标:`GET /metrics`(需 X-API-Key,任意项目成员可读,只统计
调用者是成员的项目),返回任务总数、各状态计数、成功率、
平均耗时、fallback 总次数、成本合计(actual 与 estimated 分开)。
- 备份:`python3 -m cloud.backup --db <库> --out <目录> [--keep 3]`,
用 SQLite backup API 在线备份,轮转只留最近 3 份(命名含时间戳),
失败明确报错不静默;备份文件本身就是可打开的 SQLite 库。
- DB 接口迁移:server 及 auth/memory/kb/cost/oauth/push/webhooks
的 Store 连接已统一走 cloud.db 的 WAL + busy_timeout 连接
(Postgres 后端已接通,驱动缺失时启动即失败、不先建 SQLite
文件,这一点迁移后未退化)。
## Docker 沙箱执行器(本轮新增)
- 用法:worker 选 docker 执行器——
`python3 -m cloud.worker --db <库> --executor docker
[--docker-image codex-app-server:latest] [--docker-cpus 2]
[--docker-memory 2g] [--docker-user 1000:1000]
[--docker-worktrees-root <目录>] [--docker-timeout <秒>]`;
或在代码里直接传 `DockerExecutor(...)` 给 `Worker(executor=...)`。
选 docker 但本机无 docker 时任务明确失败,error_text 含
docker 缺失,绝不静默降级为本地执行;选 local 时行为不变。
- 命令构造(示例,任务 id 为 t1、worktree 为 /tmp/wt-t1 时):
`docker run --rm --name codex-t1 --user 1000:1000 --cpus 2
--memory 2g -v /tmp/wt-t1:/work -v <代码目录>:/app:ro -w /work
-e XCM_API_KEY codex-app-server:latest python3 /app/host.py
dispatch --name t1 --task <提示>`。要点:只挂任务自己的
worktree(可写)和代码目录(只读),绝不挂宿主根目录、绝不
挂任何含密钥的目录;容器名由任务 id 派生(仅安全字符);
`-e` 只传 env_key 名,不带值,key 缺失时容器根本不启动;
资源限额默认 2 核 / 2G,可配置;容器内进程以非 root 用户跑。
- 镜像:`cloud/docker/Dockerfile`,基于 python:3.12-slim,装
git,复制调用方提供的配对 codex 二进制(codex +
codex-code-mode-host + codex-resources)。构建命令示例(在
仓库根目录,构建上下文就是仓库根):
`docker build -f cloud/docker/Dockerfile
-t codex-app-server:latest --build-arg CODEX_BIN_DIR=bin .`
bin/ 不进仓库,构建主机上没有配对二进制就构建失败;代码
不在镜像里,运行时只读挂载到 /app,代码改了不用重新构建,
codex 版本变了才需要重新构建;镜像里不含任何 key。
- 清理与取消:`docker run --rm`,正常失败也自动删容器;超时
时先 docker kill 再 docker rm -f;worker 取消时按任务派生的
容器名 docker kill,不只改数据库状态。
- 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行
器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火
墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称
已实现网络白名单。另有一项待实测:codex 自带的 bubblewrap
沙箱在容器内是否可用,取决于宿主的容器权限配置,本镜像不
假装解决;若 bwrap 不可用,host.py 的 sandbox 参数仍可退到
read-only / workspace-write 的控制面约束。
- 仍需在外部主机上实测(本 VM 没有 Docker,本轮未连外部主机):
真实 docker build、真实 docker run、资源限额在真实负载下的
表现、bwrap 在容器内的可用性、以及取消时容器确实消失。