Files
cc-web/docs/gitea-workflow/ARCHITECTURE.md

511 lines
24 KiB
Markdown
Raw Permalink 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.

# 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:<owner>/<repo>` | 仓库锁、工作区和并发分区 |
| `resourceKey` | `<repoKey>:<kind>:<number>` | Issue/PR 资源身份 |
| `sessionKey` | 同 `resourceKey` | 一个资源一个 Codex App thread |
| `deliveryKey` | `default:<X-Gitea-Delivery>` | 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 和签名 headerHMAC 失败立即 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` turnprompt 明确“只补发上一轮最终文本,不重复修改”。
查询异常/状态未知不自动写操作,直接标记 `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: <HMAC-SHA256(raw_body, webhook_secret)>
```
适配器至少抽取以下规范化结构;原始 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": "<secret-ref>"}
}
}
}
```
当运行时拒绝 `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<!-- ccweb taskId=… turnId=… -->",
"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-01AC-13 编写协议 mock、状态迁移、重启、
并发、回帖确认和安全测试。任何无法满足的契约必须在对应审计/错误码中显式降级,
不得静默跳过。
实现已按本文边界落地;后续修改不得绕过验签、状态机、幂等键和回帖核验,也不得
新增未审计的外部写操作。