# Gitea Webhook 驱动 ccweb 工作流架构设计 > 设计基线:[PRD.md](./PRD.md) > 目标:定义并实现可实施、可恢复、可审计的协议与数据边界。 > MVP:单 Gitea 实例、全局 `ccweb-bot`。 ## 1. 设计原则与边界 1. **事件先持久化,执行后置**:Webhook 线程只做验签、去重、规范化和入队, 不同步等待 Codex App 或 Gitea 回帖。 2. **仓库是互斥单元,资源是会话单元**:`repoKey` 决定工作区锁, `sessionKey` 决定 Issue/PR 独立线程;二者不能混用。 3. **所有外部动作可重放且幂等**:delivery、起始回执、MCP 最终回帖和 REST 兜底均带关联键,重复请求只能得到已有结果。 4. **Gitea 是事实来源**:cc-web 的状态是工作流镜像;最终用户内容必须能在 Gitea Issue/PR 中查到,MCP 返回成功不等于回帖已落库。 5. **运行时协议贴近 Codex App**:初始化后发送 `initialized`,能力探测失败只 降级记录;使用 collaboration mode 时参数放入 `settings`,不重复传顶层字段。 6. **线程级注入来源上下文**:Gitea MCP 配置随 `thread/start.config` 注入, 不使用长驻 app-server 的进程级环境承载某个来源会话的上下文。 当前实现采用项目现有 JSON 持久化(`lib/gitea-workflow-store.js`),并由 `server.js` 挂接 HTTP 路由、`lib/gitea-workflow-*.js` 提供领域实现、`public/` 提供管理镜像。所有实现仍必须满足下文的原子写、唯一约束和恢复语义。 ## 2. 逻辑拓扑 ```text ┌──────────────┐ HMAC Webhook ┌──────────────────────────┐ │ Gitea 实例 │ ─────────────────────────▶ │ Webhook Adapter │ │ Issue/PR │ │ 验签/规范化/去重/入队 │ └──────┬───────┘ └──────────┬───────────────┘ │ REST 查询/兜底 │ │ ▼ │ ┌──────────────────────┐ │ │ Workflow Store │ │ │ tasks/sessions/audit │ │ └──────────┬───────────┘ │ │ │ ┌──────────▼───────────┐ │ │ Scheduler │ │ │ repo lock │ │ └──────┬───────┬────────┘ │ │ │ │ ┌────────────▼─┐ ┌───▼─────────────┐ │ │ Workspace │ │ Session Manager │ │ │ clone/fetch │ │ thread/turn │ │ └──────┬────────┘ └───┬─────────────┘ │ │ │ JSON-RPC │ │ ▼ │ │ ┌──────────────────┐ │ └─────▶│ Codex App Server │ │ │ codexapp + yolo │ │ └────────┬─────────┘ │ │ stdio MCP │ ▼ └─────────────────────────────────────────────────┌───────────────┐ │ gitea-mcp │ │ 官方 stdio │ └───────────────┘ 运维 API/UI ──只读查询/控制──▶ Workflow Store + Scheduler + Audit ``` 组件职责: | 组件 | 职责 | 不负责 | |---|---|---| | Webhook Adapter | 原始 body 验签、delivery 去重、Bot/mention 过滤、规范化 | 启动 Agent、直接执行 Git | | Workflow Store | 任务、会话、turn、评论、仓库、控制面和审计的持久化 | 远程调用 | | Scheduler | FIFO、每仓库 lease、恢复扫描 | 解析业务 prompt | | Workspace Manager | 自动登记、clone/fetch、分支上下文、脏目录保护 | 修改用户代码内容 | | Session Manager | Codex App initialize、thread/turn、事件序列、取消 | 直接向 Gitea 发最终正文 | | MCP Bridge | 为线程启动官方 `gitea-mcp` stdio 并传递 host/token | 代替 Agent 决策 | | Reply Verifier | 查询评论、校验 taskId/turnId/resourceKey、触发有限补偿 | 无条件重复回帖 | | Control/Audit API | 暂停、停用、取消、中止、查询和审计导出 | 让用户切换 Agent 模式 | ## 3. 标识符与幂等键 | 名称 | 格式/来源 | 唯一性与用途 | |---|---|---| | `instanceId` | 固定 `default` | 为未来多实例预留命名空间 | | `repoKey` | `default:/` | 仓库锁、工作区和并发分区 | | `resourceKey` | `::` | Issue/PR 资源身份 | | `sessionKey` | 同 `resourceKey` | 一个资源一个 Codex App thread | | `deliveryKey` | `default:` | Webhook 去重;无 header 时使用可靠 payload ID,否则拒绝 | | `taskId` | `gitea-task-` + deliveryKey 的 SHA-256 前缀 | 一次用户评论触发的一次可审计任务;同一 delivery 重放保持不变 | | `threadId` | Codex App 返回值 | 跨任务复用同一资源上下文 | | `turnId` | Codex App 每轮返回值 | 最终回帖确认和恢复边界 | | `commentId` | Gitea 返回值 | 状态/最终/兜底评论幂等引用 | | `leaseId` | UUID + expiry | Scheduler 持有仓库锁和全局槽位的租约 | 所有唯一键写入必须在同一事务/原子文件替换中完成。重复请求返回原记录,不 生成新的 `taskId`、`turnId` 或评论。 ## 4. 持久化数据模型 字段名是跨模块契约;时间统一 ISO-8601 UTC,所有枚举使用小写 snake_case。 ### 4.1 `webhook_deliveries` ```json { "deliveryKey": "default:8f7…", "deliveryId": "8f7…", "eventType": "issue_comment", "receivedAt": "2026-08-24T08:00:00Z", "signatureValid": true, "normalized": true, "taskId": "task-uuid-or-null", "status": "accepted|ignored|duplicate|rejected", "payloadHash": "sha256:…", "expiresAt": "2026-09-23T08:00:00Z" } ``` `deliveryKey` 唯一;保留期必须覆盖 Gitea 可能的重试窗口。验签失败可只写安全 审计,不写入可用于重放的业务 payload。 ### 4.2 `repositories` ```json { "repoKey": "default:acme/widget", "instanceId": "default", "owner": "acme", "name": "widget", "cloneUrl": "https://gitea.example/acme/widget.git", "workspacePath": "/workspace/default/acme/widget", "defaultBranch": "main", "status": "active|disabled|blocked", "registeredAt": "2026-08-24T08:00:00Z", "lastFetchAt": "2026-08-24T08:02:00Z", "version": 3 } ``` 注册采用 compare-and-swap:并发首次触发只能产生一条记录。Token 不落在 `cloneUrl`;`workspacePath` 必须经过根目录规范化,禁止路径穿越。 ### 4.3 `sessions` ```json { "sessionKey": "default:acme/widget:issue:42", "resourceKey": "default:acme/widget:issue:42", "kind": "issue", "number": 42, "threadId": "thread_…", "runtime": {"app": "codexapp", "mode": "yolo"}, "mcp": {"server": "gitea", "transport": "stdio", "hostRef": "gitea-main"}, "status": "active|waiting_user|closed", "lastTaskId": "task-uuid", "lastTurnId": "turn_…", "createdAt": "2026-08-24T08:03:00Z", "updatedAt": "2026-08-24T08:10:00Z", "version": 7 } ``` `sessionKey` 唯一,`threadId` 创建后不可因普通重试而替换;只有明确恢复失败 并经审计后才允许新线程。 ### 4.4 `tasks` ```json { "taskId": "task-uuid", "deliveryKey": "default:8f7…", "repoKey": "default:acme/widget", "resourceKey": "default:acme/widget:issue:42", "sessionKey": "default:acme/widget:issue:42", "commentId": 101, "author": "alice", "instruction": "请修复……", "state": "queued", "attempt": 0, "replyAttempt": 0, "threadId": "thread_…", "turnId": null, "leaseId": null, "nextRetryAt": null, "errorCode": null, "createdAt": "2026-08-24T08:00:01Z", "updatedAt": "2026-08-24T08:00:01Z", "stateVersion": 1 } ``` 任务状态必须与 PRD 第 6 节一致;`stateVersion` 防止并发 worker 的旧写覆盖新写。 ### 4.5 `turns`、`comments`、`audit_events` 与控制面 ```json { "turn": { "turnId": "turn_…", "taskId": "task-uuid", "threadId": "thread_…", "kind": "normal|reply_retry", "status": "started|completed|interrupted|failed|cancelled", "eventSeq": 18, "startedAt": "…", "completedAt": null }, "comment": { "commentKey": "task-uuid:status:running", "taskId": "task-uuid", "kind": "status|mcp_final|rest_fallback", "resourceKey": "default:acme/widget:issue:42", "turnId": "turn_…", "commentId": 102, "bodyHash": "sha256:…", "status": "pending|sent|confirmed|failed" }, "audit_event": { "eventId": "audit-uuid", "taskId": "task-uuid-or-null", "action": "webhook.accept|task.claim|mcp.call|reply.confirm|…", "actor": "system|admin:alice|gitea:user:alice", "fromState": "running", "toState": "verifying_reply", "deliveryId": "8f7…", "turnId": "turn_…", "commentId": 102, "errorCode": null, "timestamp": "…", "metadata": {"attempt": 0} }, "control": { "globalPaused": false, "pauseReason": null, "updatedBy": "admin:alice", "updatedAt": "…", "version": 4 } } ``` `commentKey` 是起始/异常结果回执的幂等键;最终回帖以 `taskId+turnId` 为主键,REST 兜底不能覆盖已确认的 MCP 评论。 ## 5. 处理时序与事务边界 ### 5.1 Webhook 接收 1. 读取 raw body 和签名 header;HMAC 失败立即 401。 2. 提取 delivery/event header,计算 `payloadHash`;在 `webhook_deliveries` 上做唯一插入。 3. 已存在 delivery 时返回原处理结果;首次插入后解析事件。 4. 过滤非评论、无 mention、Bot 自评论、停用仓库;分别落为 `ignored/rejected`,并追加审计。 5. 在一个原子操作中登记仓库(若需要)、创建 Session 引用和 Task,状态为 `received` → `queued`,写入 delivery 与 task 的关联。 6. 立即返回 202(已入队)和 `taskId`;不等待 clone、Codex App 或评论发送。 ### 5.2 调度与工作区 1. Scheduler 扫描 `queued`、到期 `retry_wait` 和管理员解除的 `blocked_workspace`。 2. 先检查全局暂停、仓库 active,再以租约方式获取 repo lock。 3. CAS 将任务置为 `preparing`;租约含 `leaseId/owner/expiresAt`,心跳续租。 4. Workspace Manager 创建目录或执行安全 fetch。失败按错误矩阵决定 `blocked_workspace`、`retry_wait` 或 `failed`。 5. 任务入队后发送唯一的幂等 `giteabot: 已收到` 起始回执,再将任务置为 `running` 并启动/恢复 turn;运行中、等待用户和成功状态不再额外发评论。 ### 5.3 Codex App 与 MCP 1. 对长驻 app-server 只初始化一次;成功后发送 `initialized` notification。 2. best-effort 探测 `experimentalFeature/enablement/set(goals=true)` 和 `collaborationMode/list`;探测失败只记日志并保留降级能力。 3. `thread/start` 注入工作区 cwd 和线程级 `mcp_servers.gitea`;若使用 collaboration mode,`model/reasoning_effort/developer_instructions` 放在 `collaborationMode.settings`,顶层不得重复。 4. `turn/start` 仅传本轮指令和关联元数据,落库返回的 `turnId`;按事件序号 更新 `turns.eventSeq`,重复事件不重复处理。 5. turn 完成后根据 stop reason 判断 `waiting_user`、执行失败或进入 `verifying_reply`。 ### 5.4 回帖确认与补偿 1. 仅当 Agent 通过 gitea-mcp 发起最终评论后,才把任务置为 `verifying_reply`。 2. Reply Verifier 通过 Gitea API/MCP 查询原资源评论,匹配 Bot 作者、 `taskId`、`turnId` 和 resourceKey 标记;确认成功则 `succeeded`。 3. 只有 Gitea 查询明确返回“评论缺失”时,才将 `replyAttempt += 1` 并最多创建 一个 `reply_retry` turn;prompt 明确“只补发上一轮最终文本,不重复修改”。 查询异常/状态未知不自动写操作,直接标记 `failed_reply` 并发送状态告警。 4. `reply_retry` 仍无法确认时,使用 REST Bot Token 发一条带同样关联标记的兜底评论; 成功为 `succeeded_with_rest_fallback`,失败为 `failed_reply`。每一步都写审计。 ### 5.5 重启恢复 启动恢复扫描按以下顺序执行: 1. 未过期租约保留 owner;过期租约释放 repo lock 并记录事件。 2. `queued` 直接重新入队;`retry_wait` 按 `nextRetryAt` 等待。 3. `running/preparing/aborting` 若无活跃 worker,标记 `retry_wait(errorCode=interrupted_by_restart)`;超过执行重试上限则 `failed`。 4. `waiting_user`、所有终态和已确认评论不自动重跑。 5. 恢复过程使用 `stateVersion` CAS,单实例 Scheduler 只能产生一个有效租约。 ### 5.6 规范状态与转移契约 实现层必须使用以下完整枚举;状态名、终态和转移条件与 PRD 第 6 节保持一致: | 当前状态 | 允许的下一状态 | 触发者/守卫 | |---|---|---| | `received` | `queued` / `ignored` / `duplicate` / `rejected` | Webhook Adapter 完成规范化 | | `queued` | `preparing` / `cancelled` | Scheduler 已取得全局槽位和仓库租约;取消请求 | | `preparing` | `running` / `blocked_workspace` / `retry_wait` / `failed` | Workspace/Session Manager 结果 | | `running` | `waiting_user` / `verifying_reply` / `retry_wait` / `aborting` / `failed` | Codex App stop reason、错误或管理员中止 | | `waiting_user` | `queued` / `cancelled` / `failed` | 新评论、取消请求或会话不可恢复 | | `verifying_reply` | `succeeded` / `retry_wait` / `failed_reply` | Reply Verifier 结果;仅允许一次 reply retry | | `retry_wait` | `preparing` / `running` / `verifying_reply` / `failed` | `nextRetryAt` 到期且未超过上限 | | `blocked_workspace` | `queued` / `cancelled` | 管理员确认工作区可用或取消 | | `aborting` | `aborted` / `failed` | Codex App 取消确认或超时 | | `succeeded` / `succeeded_with_rest_fallback` / `failed` / `failed_reply` / `cancelled` / `aborted` / `ignored` / `duplicate` / `rejected` | 无 | 终态,不得创建新 turn | 每次转移都必须带 `fromState/toState/stateVersion` 和审计事件;未知状态或非法 转移拒绝写入并告警。`aborting` 释放资源前不得被误标为 `aborted`, `succeeded_with_rest_fallback` 必须保留原始 MCP 回帖失败原因。 ## 6. 接口契约 ### 6.1 Webhook HTTP **Endpoint**:`POST /api/gitea/webhook`(路径可由部署配置映射,但语义固定) 请求要求: ```http Content-Type: application/json X-Gitea-Event: issue_comment X-Gitea-Delivery: 8f7e… X-Gitea-Signature: ``` 适配器至少抽取以下规范化结构;原始 payload 只按安全保留策略保存 hash: ```json { "instanceId": "default", "deliveryId": "8f7e…", "eventType": "issue_comment|pull_request_comment", "repo": {"owner": "acme", "name": "widget", "cloneUrl": "…"}, "resource": {"kind": "issue|pull_request", "number": 42}, "comment": {"id": 101, "body": "@ccweb-bot 请修复…", "createdAt": "…"}, "actor": {"login": "alice", "isBot": false}, "refs": {"base": "main", "head": "feature/x"} } ``` 响应语义: | HTTP | 场景 | 业务效果 | |---|---|---| | 202 | 验签通过且首次入队 | 返回 `{deliveryId, taskId, state:"queued"}` | | 200 | 重复 delivery、合法但忽略的事件 | 返回原 task 或 `{status:"ignored"}` | | 400 | JSON/必需字段无法解析 | 不入队,记录 `invalid_payload` | | 401 | HMAC 缺失或错误 | 不入队,不暴露内部原因 | | 409 | 仓库被管理员停用且策略为拒绝 | 返回 `repository_disabled`,不创建运行任务 | | 500/503 | 持久化不可用 | Gitea 可重试;不得声称已入队 | ### 6.2 控制与查询 API(逻辑契约) 实现可映射到现有 cc-web 路由,但字段和幂等语义保持不变: | 方法 | 逻辑路径 | 作用 | |---|---|---| | GET | `/api/gitea-workflow/overview` | 全局暂停、运行槽位、队列摘要 | | GET | `/api/gitea-workflow/tasks?repoKey=&resourceKey=&state=` | 分页查询任务 | | GET | `/api/gitea-workflow/tasks/:taskId` | 任务、turn、评论和错误详情 | | POST | `/api/gitea-workflow/control/pause` / `resume` | 全局暂停/恢复;body 带 reason | | POST | `/api/gitea-workflow/repos/:repoKey/disable` / `enable` | 仓库停用/启用 | | POST | `/api/gitea-workflow/tasks/:taskId/cancel` | 仅取消 queued/waiting_user | | POST | `/api/gitea-workflow/tasks/:taskId/abort` | 请求中止 running turn | | GET | `/api/gitea-workflow/audit?...` | 按关联 ID/时间范围查询审计 | 控制请求必须带操作者身份、幂等 `requestId` 和 `reason`;重复 requestId 返回首次 结果,不重复发送取消或评论。API 不接收模型、MCP 工具参数或任意 Git 命令。 ### 6.3 Codex App JSON-RPC 形状 初始化链路: ```text initialize → initialized(notification) → experimentalFeature/enablement/set(goals=true) [best effort] → collaborationMode/list [best effort] → thread/start ``` 线程配置契约(示意): ```json { "cwd": "/workspace/default/acme/widget", "collaborationMode": { "settings": { "model": "<固定配置>", "reasoning_effort": "<固定配置>", "developer_instructions": "<工作流系统约束>" } }, "mcp_servers": { "gitea": { "command": "gitea-mcp", "args": ["-t", "stdio", "-H", "https://gitea.example"], "env": {"GITEA_ACCESS_TOKEN": ""} } } } ``` 当运行时拒绝 `collaborationMode` 或返回 unknown field 时,记录一次 `capability_downgrade`,本轮退回普通 turn;不能因为 goals/list 探测失败而终止 app-server。使用 collaboration mode 时,`turn/start` 顶层不得再传 `model` 或 `effort`。 ### 6.4 MCP 与 Gitea REST 回帖 起始/异常回执与最终回帖分离:起始 `已收到` 和必要的异常终态回执由 cc-web 通过 Gitea REST 发送,使用 `commentKey=taskId:status:state` 幂等;正常最终答案 必须优先由官方 gitea-mcp 发出,REST 只允许作为确认失败后的最终正文兜底。这样 既能让用户知道任务已接收,又不会用一串运行状态文本污染 Issue/PR 讨论串。 架构对官方 gitea-mcp 的具体工具名做一层逻辑适配,避免版本差异泄漏到任务状态: ```json { "operation": "gitea.comment.create", "resource": {"kind": "issue|pull_request", "number": 42}, "body": "最终答案\n", "idempotencyKey": "task-uuid:turn_…" } ``` 适配器必须将工具返回映射为 `{accepted, commentId, errorCode}`,并再次查询确认。 REST 兜底使用 Bot Token 调用 Gitea 的对应 Issue/PR 评论接口,body 必须复用 `taskId/turnId` 标记;若 REST 返回 2xx 但查询不到,状态仍为 `failed_reply`,不能 虚报成功。 ## 7. 并发、租约与一致性 - 调度只依赖 `repoKey` 级 lease;没有全局 semaphore 或全局并发槽位。 - 仓库 lease 的键为 `repoKey`,TTL 必须短于 worker 心跳间隔的可容忍失联窗口; 释放必须校验 `leaseId`,防止旧 worker 解锁新 worker 的租约。 - 领取任务采用 `stateVersion` CAS:`queued → preparing` 成功者才可继续。 - 任务状态、评论幂等记录和审计事件先写入,再执行外部调用;外部调用结果以 append-only 事件补写,重试读取最后已知结果。 - 进程内锁只作性能优化,正确性依赖持久 lease;单实例 MVP 仍按可恢复模型设计, 以便未来横向扩展。 ## 8. 失败场景矩阵 | 场景 | 检测 | 状态/动作 | 是否重试 | |---|---|---|---| | HMAC 缺失/错误 | 常量时间校验失败 | 401、`rejected` 审计 | 否 | | delivery 重复 | 唯一键冲突 | 返回原结果、`duplicate` | 否 | | payload 不完整 | schema 校验失败 | 400、`invalid_payload` | 否 | | 仓库停用 | `repositories.status=disabled` | 拒绝新任务 | 否 | | clone/fetch 鉴权失败 | Git exit code/HTTP 401 | `retry_wait`,超过上限 `failed` | 最多 1 次 | | 工作区脏或冲突 | git status/lock 检查 | `blocked_workspace`,不 reset | 管理员解除后 1 次 | | 全局暂停 | control CAS | 保持 `queued`,不领取 | 恢复后 | | 租约过期 | TTL/心跳超时 | 释放资源并恢复扫描 | 最多 1 次 | | Codex App 不可达 | JSON-RPC timeout | `retry_wait` | 最多 1 次 | | gitea-mcp 命令缺失 | 启动前可执行文件检查失败 | `failed` + 一条 `giteabot:` 失败结果回执 | 不重试,修复命令后重新触发 | | MCP 进程启动失败 | stdio 子进程启动即退出 | `failed` + 一条 `giteabot:` 失败结果回执 | 不重试,修复命令后重新触发 | | MCP initialize 握手失败/超时 | stdio JSON-RPC `initialize` 无成功响应 | `failed` + 一条 `giteabot:` 失败结果回执 | 不重试,修复命令后重新触发 | | turn 被重启中断 | 无活跃 worker | `retry_wait(interrupted_by_restart)` | 最多 1 次 | | 用户补充问题 | stop reason/waiting 信号 | `waiting_user`,保留 thread | 新评论触发 | | 管理员中止超时 | cancel 无确认 | `failed` 或 `aborted`,强制释放 lease 并审计 | 否 | | MCP 最终回帖缺失 | 查询无匹配评论 | 一次 `reply_retry` turn | 仅 1 次 | | REST 兜底失败 | 非 2xx/查询不到 | `failed_reply` + 告警 | 不自动重试 | | Bot 自评论回调 | actor/login 匹配 Bot | `ignored` | 否 | 错误码建议使用稳定小写值(如 `invalid_signature`、`duplicate_delivery`、 `workspace_dirty`、`app_server_timeout`、`mcp_reply_missing`),展示文本可本地化。 ## 9. 安全与审计设计 ### 9.1 信任边界 Webhook Secret 只在入口使用;Bot Token 只在 Gitea MCP/REST 出站边界使用; Codex App 收到的评论、Issue 描述、代码和 PR 内容全部视为不可信数据。系统提示 明确禁止将这些内容解释为更改运行时权限、读取 Secret 或关闭审计的指令。 ### 9.2 权限与凭据 - 官方 gitea-mcp 按已确认需求开放写权限,但通过固定实例 host、专用 Bot Token、 运维暂停/中止和完整审计进行约束。 - 日志仅记录 `hostRef/tokenRef`,不记录 Authorization、完整命令环境或原始 Secret。 - Git clone/fetch 的凭据使用短生命周期注入,完成后清理;remote URL 不含 Token。 ### 9.3 审计完整性 每个外部副作用前写入 intent 事件,完成后写入 result 事件;事件不可更新,只能 追加补偿或人工操作事件。审计关联链必须可由 `deliveryKey → taskId → sessionKey/threadId → turnId → commentId` 重建。 ## 10. 验收与实现交接 实现方需按 [PRD.md](./PRD.md) 的 AC-01~AC-13 编写协议 mock、状态迁移、重启、 并发、回帖确认和安全测试。任何无法满足的契约必须在对应审计/错误码中显式降级, 不得静默跳过。 实现已按本文边界落地;后续修改不得绕过验签、状态机、幂等键和回帖核验,也不得 新增未审计的外部写操作。