# 云端控制面(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 锁上限,生产量上来后需评估 换库或单写者队列。