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

24 KiB
Raw Permalink Blame History

Gitea Webhook 驱动 ccweb 工作流架构设计

设计基线: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. 逻辑拓扑

┌──────────────┐       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 持有仓库锁和全局槽位的租约

所有唯一键写入必须在同一事务/原子文件替换中完成。重复请求返回原记录,不 生成新的 taskIdturnId 或评论。

4. 持久化数据模型

字段名是跨模块契约;时间统一 ISO-8601 UTC所有枚举使用小写 snake_case。

4.1 webhook_deliveries

{
  "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

{
  "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 不落在 cloneUrlworkspacePath 必须经过根目录规范化,禁止路径穿越。

4.3 sessions

{
  "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

{
  "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 turnscommentsaudit_events 与控制面

{
  "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状态为 receivedqueued,写入 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_workspaceretry_waitfailed
  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 modemodel/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 作者、 taskIdturnId 和 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_waitnextRetryAt 等待。
  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

EndpointPOST /api/gitea/webhook(路径可由部署配置映射,但语义固定)

请求要求:

Content-Type: application/json
X-Gitea-Event: issue_comment
X-Gitea-Delivery: 8f7e…
X-Gitea-Signature: <HMAC-SHA256(raw_body, webhook_secret)>

适配器至少抽取以下规范化结构;原始 payload 只按安全保留策略保存 hash

{
  "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/时间范围查询审计

控制请求必须带操作者身份、幂等 requestIdreason;重复 requestId 返回首次 结果不重复发送取消或评论。API 不接收模型、MCP 工具参数或任意 Git 命令。

6.3 Codex App JSON-RPC 形状

初始化链路:

initialize → initialized(notification)
          → experimentalFeature/enablement/set(goals=true) [best effort]
          → collaborationMode/list [best effort]
          → thread/start

线程配置契约(示意):

{
  "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 顶层不得再传 modeleffort

6.4 MCP 与 Gitea REST 回帖

起始/异常回执与最终回帖分离:起始 已收到 和必要的异常终态回执由 cc-web 通过 Gitea REST 发送,使用 commentKey=taskId:status:state 幂等;正常最终答案 必须优先由官方 gitea-mcp 发出REST 只允许作为确认失败后的最终正文兜底。这样 既能让用户知道任务已接收,又不会用一串运行状态文本污染 Issue/PR 讨论串。

架构对官方 gitea-mcp 的具体工具名做一层逻辑适配,避免版本差异泄漏到任务状态:

{
  "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 的键为 repoKeyTTL 必须短于 worker 心跳间隔的可容忍失联窗口; 释放必须校验 leaseId,防止旧 worker 解锁新 worker 的租约。
  • 领取任务采用 stateVersion CASqueued → 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 无确认 failedaborted,强制释放 lease 并审计
MCP 最终回帖缺失 查询无匹配评论 一次 reply_retry turn 仅 1 次
REST 兜底失败 非 2xx/查询不到 failed_reply + 告警 不自动重试
Bot 自评论回调 actor/login 匹配 Bot ignored

错误码建议使用稳定小写值(如 invalid_signatureduplicate_deliveryworkspace_dirtyapp_server_timeoutmcp_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 的 AC-01AC-13 编写协议 mock、状态迁移、重启、 并发、回帖确认和安全测试。任何无法满足的契约必须在对应审计/错误码中显式降级, 不得静默跳过。

实现已按本文边界落地;后续修改不得绕过验签、状态机、幂等键和回帖核验,也不得 新增未审计的外部写操作。