diff --git a/.env.example b/.env.example index 4b3b50c..3222517 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,15 @@ CODEX_PATH=codex # PushPlus Token(可选,首次启动会自动迁移到 config/notify.json) PUSHPLUS_TOKEN= + +# Gitea Workflow(MVP:单实例、全局 ccweb-bot) +CC_WEB_GITEA_HOST=https://gitea.example.com +CC_WEB_GITEA_INSTANCE_ID=default +CC_WEB_GITEA_BOT_LOGIN=ccweb-bot +CC_WEB_GITEA_BOT_TOKEN= +CC_WEB_GITEA_WEBHOOK_SECRET= +CC_WEB_GITEA_WORKSPACE_ROOT=/var/lib/ccweb/workspaces +# 可选:替换 cc-web 自带的 gitea-mcp;留空时自动使用 bin/gitea-mcp +# CC_WEB_GITEA_MCP_COMMAND=/opt/gitea-mcp/bin/gitea-mcp +# gitea-mcp stdio initialize 预检超时(毫秒,默认 8000,上限 30000) +CC_WEB_GITEA_MCP_STARTUP_TIMEOUT_MS=8000 diff --git a/.gitignore b/.gitignore index 43c7b4f..4282be7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ config/notify.json config/auth.json config/model.json config/codex.json +config/gitea-workflow.json +config/gitea-workflow-management.json +config/gitea-workflow-secrets.json +config/gitea-webhook-deliveries.json +config/gitea-repositories.json +gitea-workspaces/ CLAUDE.md dist-exe/* !dist-exe/*.tar.gz diff --git a/.planning/2026-08-23-gitea-workflow/findings.md b/.planning/2026-08-23-gitea-workflow/findings.md new file mode 100644 index 0000000..1611172 --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/findings.md @@ -0,0 +1,47 @@ +# Gitea Workflow 研究与决策 + +## 需求 + +- Gitea 实例级 Webhook 指向 cc-web;用户在 Issue/PR 普通评论中 `@ccweb-bot`。 +- 未登记仓库首次合法触发自动接入,clone 到可配置工作区根目录。 +- 同一仓库串行、不同仓库并行;任务队列、会话映射和回执状态必须持久化。 +- 所有后台会话固定使用 Codex App 与 yolo;Gitea 是唯一主交互入口。 +- Agent 通过官方 `gitea-mcp` 读取上下文、研究、修改和回帖;cc-web 负责状态与最终兜底。 + +## 本地代码发现 + +- `server.js` 已有持久会话创建、`handleMessage`、Codex App 线程、MCP 配置和 turn 完成路径。 +- `codexAppThreadConfig()` 已将运行时 MCP 组装成 `thread/start.config`;可扩展为工作流专属 `gitea` 配置。 +- `handleCodexAppTurnComplete()` 是最合适的 turn 完成监听点。 +- `handleInternalMcpApi()` 是 cc-web 内部 MCP API,不应暴露为 Gitea 公网 Webhook。 +- 项目无 Node SQLite 依赖;现有会话和配置以 JSON 文件持久化,工作流状态可采用原子 JSON 文件或新增独立存储模块。 + +## 官方 gitea-mcp + +- 官方仓库:`https://gitea.com/gitea/gitea-mcp`。 +- 支持本机 stdio 与 HTTP;本任务选择 stdio。 +- 支持 `GITEA_HOST`、`GITEA_ACCESS_TOKEN`,可通过 `-t stdio -H ` 启动。 +- 工具覆盖 Issue、PR、仓库、文件、分支、提交、Release、Actions 等。 +- 支持 scope/tool 过滤,但用户要求 MVP 开放全部写权限;必须配套暂停、中止和审计。 + +## 外部方案 + +- Matea:最接近完整 Gitea Webhook Agent 网关,可参考其签名、去重、队列和状态机;社区规模仍小。 +- wshm:支持多 Forge 和后台同步,但不是 cc-web 会话桥接。 +- pi-dispatch:队列、预算和沙箱值得参考,但使用 pi Agent。 +- gitea-claude-agent:验证 `@机器人 → 多轮澄清 → PR` 交互,但依赖 Gitea Actions。 + +## 关键技术约束 + +- Webhook 必须在验签、delivery 去重、Bot 自评论过滤后才创建任务。 +- 每个 turn 生成唯一标识,正常最终评论由 MCP 发送;Webhook/API 查询确认真实回帖。 +- 任务完成但没有回帖时,只允许在同一会话发起一次“只补发回执、不重复修改”的隐藏 turn。 +- 回执仍失败才使用 Bot Token 走 Gitea REST 兜底。 +- 共享工作区有未提交修改时不 reset、不覆盖;当前会话可继续,其他会话排队。 + +## 已落地实现 + +- 主服务入口为 `lib/gitea-workflow-service.js`,底层组合 `domain/store/queue`;避免同时维护两套运行时状态。 +- `server.js` 只负责实例化配置、Webhook 路由、工作区 runner、Codex App turn 桥接和管理 API。 +- 管理配置中的 Secret/Token 保存到独立 0600 文件,GET 配置只返回“是否已配置”;保存后重启使运行时闭环读取新凭据。 +- 回帖核验只接受 `kind=final|fallback` 的隐藏标识,状态评论不会误判为最终回帖;查询异常也只进行一次“只补发回执”补偿。 diff --git a/.planning/2026-08-23-gitea-workflow/integration-map.md b/.planning/2026-08-23-gitea-workflow/integration-map.md new file mode 100644 index 0000000..e571bb6 --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/integration-map.md @@ -0,0 +1,148 @@ +# Gitea Workflow × Codex App 集成地图 + +> 分析日期:2026-08-24 +> +> 范围:只读分析 Codex App 的 `thread/start` MCP 配置、会话创建、消息处理、turn 完成、后台运行、重启恢复和现有回归入口。未修改 `server.js`、`lib/`、`public/`。 + +## 证据与方法 + +- codebase-memory 项目:`home-cc-web`,`list_projects` 返回根目录 `/home/cc-web`,节点 6670、边 15041;`index_status` 为 `ready`。 +- 使用 `get_architecture(aspects=["all"])` 确认 `server`、`codex-app-runtime`、`regression`、`codex` 等模块边界。 +- 使用 `search_graph`、`get_code_snippet`、`trace_path(mode="calls")` 定位函数和调用关系;再以 `rg -n`/`nl -ba` 交叉核对行号。 +- 图谱无法把回调赋值完整表示出来:`handleCodexAppNotification` 的调用者列表为空,但源码显示它在 `getCodexAppClient()` 作为 `onNotification` 回调传入客户端,再由 JSONL 客户端转发。因此下图的“回调边”以源码为准。 + +## 端到端运行链路 + +```text +WebSocket message (server.js:7706-7766) + -> handleMessage (server.js:9852-10190) + -> handleCodexAppMessage (server.js:11888-11960) + -> activeCodexAppTurns + persistCodexAppTurnState(immediate) + -> startCodexAppTurn (server.js:11962-12022) + -> getCodexAppClient (server.js:11782-11845) + -> createCodexAppServerClient (lib/codex-app-server-client.js:6-223) + initialize -> initialized -> postInitialize + -> thread/start 或 thread/resume (threadParams) + -> codexAppThreadConfig -> mcp_servers.* / web_search + -> turn/start (collaborationMode) + -> app-server stdout JSONL + -> client.handleMessage (lib/codex-app-server-client.js:78-111) + -> onNotification(handleCodexAppNotification) + -> findCodexAppRouteByRuntime (server.js:11050-11066) + -> codexAppRuntime.processCodexAppNotification (lib/codex-app-runtime.js:865-1040) + -> persist state; turn/completed -> handleCodexAppTurnComplete +``` + +### 1. 线程级 MCP 配置和环境 + +| 位置 | 行号 | 当前行为 | Gitea 扩展建议 | +|---|---:|---|---| +| `server.js` `buildCcwebMcpRuntimeConfig` | 2709-2753 | 生成内置 `ccweb` MCP;HTTP 模式把来源会话/跳数放入 URL,stdio 模式生成命令及 env。 | Gitea MCP 应作为同级线程配置注入,不要改 app-server 进程全局 env。 | +| `server.js` `listRuntimeMcpServerConfigs` | 2755-2772 | 合并项目 `.codex/config.toml` MCP 与内置 `ccweb`,按规范化 server 名去重。 | 增加 workflow 专属配置的明确入口(建议由 `mcpContext`/工作流配置传入),并定义与项目同名时的优先级。 | +| `server.js` `codexAppCcwebMcpEnv` | 11680-11691 | 输出 `CC_WEB_SOURCE_SESSION_ID`、`CC_WEB_CROSS_HOP_COUNT`、内部 token 等。 | Gitea 仓库/任务 ID 应随 `thread/start.config.mcp_servers.gitea.env` 下发;不要写入 `buildCodexAppClientSpec().env`。 | +| `server.js` `codexAppThreadConfig` | 11693-11702 | 把 runtime 配置映射为 `config["mcp_servers."]`,并设置 `web_search`。 | 这是最直接的 `gitea-mcp` 注入扩展点;应保证每个线程拿到独立 host/token/workflow 上下文。 | +| `server.js` `codexAppThreadParams` | 11857-11868 | 组装 cwd、model、权限和 `config`,用于 start/resume。 | Gitea 任务必须从这里同时覆盖 `thread/start` 和 `thread/resume`,避免重试/恢复丢 MCP。 | +| `server.js` `buildCodexAppClientSpec` | 11735-11780 | 构造单例 app-server 进程的 command/args/env/signature;会剥离敏感环境变量。 | 不要把仓库 token 放此处;若新增全局配置,需纳入 signature 并评估活跃 turn 的复用策略。 | + +当前 `codexAppCcwebMcpEnv` 已证明“来源上下文线程级下发”可行。Gitea 方案应沿用这一模式,例如 `mcp_servers.gitea` 的 `command/args/env` 或 streamable HTTP 配置,而不是 dynamic tools。 + +### 2. 会话创建、turn 启动和协作参数 + +| 位置 | 行号 | 当前行为 | Gitea 扩展建议 | +|---|---:|---|---| +| `server.js` WebSocket 分发 | 7706-7766 | `message` 进入 `handleMessage`;slash 先经 `handleSlashCommand`。 | Webhook 不应伪造 WebSocket;工作流服务可调用同一内部处理函数或抽取“创建/投递消息”服务层。 | +| `server.js` `handleMessage` | 9852-10190 | 校验输入、创建/加载 JSON 会话、持久化用户消息;Codex App 分支调用 `handleCodexAppMessage`。`ws` 可为 null,`wsSend` 会安全忽略。 | 后台 Gitea 任务可复用会话创建逻辑,但应显式传 workflow 元数据、固定 agent=`codexapp`/yolo,并避免前端 slash 语义污染。 | +| `server.js` `handleCodexAppMessage` | 11888-11960 | 建立 `activeCodexAppTurns` entry,记录 `mcpContext`/retry 信息,立即写 state,然后异步启动 turn。 | 在 entry/retry state 中保留不可变的 Gitea workflow/task/repository correlation;该信息不能只存在内存。 | +| `server.js` `startCodexAppTurn` | 11962-12022 | 复用单例客户端;有 threadId 则 resume,否则 start;校验恢复线程 ID;保存 runtime thread ID;发送 turn/start。 | Gitea 首次任务走 start,后续评论/重试走同一 thread resume;将 gitea MCP 配置传入 `codexAppThreadParams`,并在 mismatch 时按任务失败处理。 | +| `server.js` `codexAppCollaborationMode` / `codexAppTurnParams` | 11704-11712、11870-11886 | collaboration mode 总是存在,`plan` 映射 plan,否则 default;model/reasoning_effort/developer instructions 放 settings,顶层不重复 model/effort。 | 后台固定 yolo 会得到 mode=default;不要为 Gitea 任务再传顶层 model/effort,避免回到非原生协作路径。 | +| `server.js` `codexAppPostInitialize` | 11714-11733 | initialize 后 best-effort 探测 goals 和 `collaborationMode/list`,失败只记日志。 | Gitea 集成不应依赖探测成功;可在能力缺失时记录降级并继续普通 turn。 | + +### 3. JSON-RPC 客户端、通知、服务端请求 + +| 位置 | 行号 | 当前行为 | Gitea 扩展建议 | +|---|---:|---|---| +| `lib/codex-app-server-client.js` `handleMessage` | 78-111 | 以 `id` 区分 pending response/server request;无 `id` 的 method 交给 `onNotification`。 | 回执监听应基于 turn/thread ID,不要解析 UI 文本;保留未知 method 日志。 | +| `lib/codex-app-server-client.js` `start` | 140-190 | 启动 stdio app-server,绑定 readline,initialize/initialized,再运行 `postInitialize`;退出 reject 所有 pending。 | gitea-mcp 子进程由 app-server 按线程配置管理;不要另起一套 app-server 连接。 | +| `server.js` `getCodexAppClient` | 11782-11845 | 全局单例 `codexAppClient`;配置 signature 变化时若有其他活跃 turn 会复用旧客户端,否则失败残留并重启。 | 队列不能通过频繁改全局 config 切换仓库;每个任务使用线程 config,避免触发 singleton stale-config 分支。 | +| `server.js` `handleCodexAppNotification` | 11105-11155 | 先处理 compact/Goal/MCP startup,再按 parent/child/磁盘恢复路由;runtime 返回 done 时进入完成处理。 | Gitea 回执监听应挂在此处或其下游的领域事件,而不是监听 WebSocket `done`;需识别 `turn/completed` 的 status/error。 | +| `server.js` `handleCodexAppServerRequest` | 11572-11608 | 处理 approval、user input、dynamic tool;未知请求按保守策略拒绝。 | gitea-mcp 是 MCP server,不应新增 dynamic tool 旁路;若需要用户确认,明确映射到工作流 waiting_user。 | +| `server.js` `handleCodexAppServerExit` / `handleCodexAppTurnFailure` | 11610-11637、12165-12170 | app-server 退出会使所有 active Codex App turn 失败;失败统一进入完成清理和重试判定。 | Gitea 任务需把此类失败映射为可恢复状态,防止一次 singleton 退出同时丢失多个仓库任务。 | + +### 4. turn 完成、后台运行与持久化恢复 + +| 位置 | 行号 | 当前行为 | Gitea 扩展建议 | +|---|---:|---|---| +| `server.js` `handleCodexAppTurnComplete` | 12024-12163 | 去重并持久化 assistant/toolCalls;处理 transient retry;删除 active entry、清理 run dir;有 ws 发 `done`,无 ws 则广播 `background_done` 并调用通知。 | 在删除 entry/清理目录前写入 workflow completion/outbox;回执确认、补触发和 REST 兜底必须幂等。建议发领域事件,避免依赖 `sendNotification`。 | +| `server.js` `handleCodexAppSteerMessage` | 12189-12419 | 运行中用 `turn/steer`;遇到 no-active-turn 会先收敛旧输出,再复用消息启动新 turn。 | Gitea 连续评论可转为 steer,但要用任务级 message/delivery ID 去重,避免 steer fallback 重复修改仓库。 | +| `server.js` `handleCodexAppAbortSession` | 12421-12451 | 发送 `turn/interrupt`,超时后强制完成;无可用 client 直接以 interrupted 完成。 | 工作流取消/仓库停用可复用,但状态机需区分用户取消、运维中止、app-server 崩溃。 | +| `server.js` `writeCodexAppTurnState` / `persistCodexAppTurnState` / `loadCodexAppTurnState` | 5039-5059、5061-5082、5093-5116 | state 原子写入、延迟 flush、大小保护;序列化当前 turn 文本/toolCalls/usage/error。 | 必须把 workflow/task/repository correlation 和回执状态纳入独立持久化(或扩展 state schema),不能只依赖 `entry.mcpContext`。 | +| `server.js` `recoverCodexAppTurnState` | 5137-5226 | 重启后恢复 partial assistant/toolCalls,写 interrupted system message,清理 run dir;不自动继续 turn。 | `queued`/`waiting_user`/`running` 的 Gitea 状态恢复应在工作流队列层实现;running 只能标为 interrupted 后按策略最多重试一次。 | +| `server.js` `recoverProcesses` + 启动调用 | 7398-7490、12950 | 启动扫描 `*-run`;Codex App state 优先走 `recoverCodexAppTurnState`,普通进程走 JSONL tail/replay。 | 工作流恢复入口应在此生命周期之后加载 durable queue,再决定是否重新投递;不要在 `recoverProcesses` 中直接执行 Gitea 网络副作用。 | +| `server.js` `updateSessionRuntimeThreadIndex` / `saveSession` | 3443-3456、4497-4520 | 保存会话时维护 thread→session O(1) 索引;未知线程有负缓存,避免通知热路径反复扫盘。 | Gitea 任务必须持久化稳定 session/thread 映射;删除仓库/会话时同步清理映射,避免跨仓库通知串线。 | +| `server.js` `findCodexAppRouteByRuntime` | 11050-11066 | parent active entry → 已知 child thread → 磁盘 adopt;child 先于 parent adoption。 | Gitea MCP 可能产生 child/collab activity,工作流回执只认 parent turn;需保留 child 事件但禁止把 child completion 当最终回执。 | + +无 WebSocket 的后台路径是可行的:`handleMessage(null, ...)` 的 `wsSend` 为空安全,`handleCodexAppTurnComplete` 会走 `background_done`/通知分支。但该分支目前没有 Gitea 专属完成回调,必须增加可持久化的领域事件或 outbox。 + +## 建议的最小扩展边界 + +1. 新增独立 Gitea Workflow 服务/存储模块:Webhook 验签、delivery 去重、仓库/任务状态机、同仓库锁、全局并发上限、outbox/审计。不要把队列逻辑塞进 `handleMessage` 或 app-server 客户端。 +2. 为 Codex App 调用增加“线程级 MCP 配置”参数(沿用 `mcpContext` 传递),在 `listRuntimeMcpServerConfigs` → `codexAppThreadConfig` → `codexAppThreadParams` 形成单向注入;官方 `gitea-mcp` 使用 stdio 或明确的 streamable HTTP 配置,token 只放线程 env/安全存储。 +3. 将 workflow correlation(实例、owner/repo、issue/PR、评论 delivery、turn ID)写入独立任务记录,并在 turn state 中保存最小引用,保证进程重启和自动重试可恢复。 +4. 在 `handleCodexAppNotification`/`handleCodexAppTurnComplete` 旁增加内部领域事件适配器:先 durable commit,再确认 Gitea 评论;缺失回执只允许一次补触发,最后 REST 兜底。 +5. 后台任务通过现有 Codex App 单例运行,但由 Gitea 队列决定何时调用;不要修改 `buildCodexAppClientSpec` 的全局环境来切换仓库。 + +## 潜在冲突与风险 + +- **单例 app-server 与多仓库并发**:`getCodexAppClient` 只有一个 client,退出会影响全部 active turns;队列必须在 client 层之上做并发/重试隔离。 +- **线程配置与进程环境边界**:`buildCodexAppClientSpec` 的 env 是进程级,不能承载某个仓库的 Gitea token;否则并发任务会串凭据。 +- **恢复丢失 MCP 上下文**:`adoptCodexAppUnroutedTurn`(`server.js:10379-10423`)新建 entry 时 `mcpContext: {}`;`recoverCodexAppTurnState` 也只恢复输出,不恢复 workflow 上下文。必须把上下文放独立任务记录或 state schema。 +- **完成清理时序**:`handleCodexAppTurnComplete` 在 12087-12088 删除 active entry/清理 run dir,之后才通知后台客户端;回执 outbox 必须在清理前落盘。 +- **自动重试副作用**:`shouldRetryCodexTransientFailure` 路径会重新启动 turn;若模型已修改仓库或已发评论,需用任务/评论/turn 幂等键防止重复写入。 +- **通知路由与未知线程**:当前有 O(1) index、磁盘 adoption 和负缓存;Gitea 不能只依赖内存 active map,否则重启后通知会变成 unrouted。 +- **现有运行保护**:`handleMessage` 会拒绝 active turn/Goal/compaction;工作流必须先做同仓库串行和取消策略,不能靠重复调用绕过保护。 +- **协作模式形状**:启用 collaborationMode 时顶层不得重复 `model`/`effort`;gitea workflow 的 yolo 固定策略应保持 `mode=default` + settings。 +- **安全边界**:Webhook HMAC、Bot 自评论过滤、delivery 去重、token 脱敏、remote URL 不落 token,需要在 Gitea 层完成,不能依赖 Codex App MCP 工具自行保证。 + +## 必须补的测试 + +### Codex App 协议/单元回归 + +- `thread/start` 首次任务与 `thread/resume` 重试都包含 `mcp_servers.gitea`,且每个 session 的 host/token/workflow env 隔离;项目 MCP、ccweb MCP、gitea MCP 同时存在时不丢失/不误去重。 +- collaborationMode 仍把 model/reasoning_effort/developer instructions 放在 `settings`,顶层没有重复 model/effort;yolo 固定为 default。 +- `handleMessage(null, ...)` 能创建后台 Codex App 会话;无 ws 完成时仍持久化 assistant/toolCalls,并触发可断言的 workflow completion/outbox,而不是只依赖 push notification。 +- turn state 原子写入/重启恢复包含 workflow correlation;恢复后 parent thread、gitea MCP 配置和回执状态不丢失;未知线程 adoption 不得把 child 事件当 parent 最终完成。 +- app-server 退出、transient capacity/reconnect、thread resume mismatch、steer no-active-turn 均只产生一次工作流状态转移和一次回执动作。 + +### Gitea Workflow 业务回归 + +- Webhook HMAC 正确/错误、重复 delivery、Bot 自评论、非 Issue/PR 普通评论过滤。 +- 首次合法触发自动接入仓库、clone/fetch 失败恢复、同仓库串行、不同仓库并行及全局并发上限。 +- 评论→session 映射稳定(`gitea + owner/repo + type + number`),连续评论 steer/resume 不重复用户消息或仓库修改。 +- turn 完成后 MCP 回帖确认:正常一次;缺失时仅一次“只补回执”隐藏 turn;再次失败才 REST 兜底;重启/重复 webhook 不重复评论。 +- running 中断、waiting_user 保留、queued 恢复、仓库暂停/全局暂停/取消排队/中止 turn 的状态机和审计日志。 +- token 不出现在日志、错误消息、session JSON、git remote URL;Webhook 重放和跨仓库 thread/child 通知不能串任务。 + +### 现有可复用测试入口 + +| 入口 | 行号 | 已覆盖内容 | +|---|---:|---| +| `scripts/regression.js` `assertCodexAppRuntimeSubAgentActivityContract` | 2276-2500 | runtime 通知到工具/子代理卡片的归一化。 | +| `scripts/regression.js` `assertCodexAppTransientReconnectContract` | 2501-2542 | reconnect 进度不结束 turn,终态错误结束 turn。 | +| `scripts/regression.js` `assertCodexAppStaleRunningRecoveryContract` | 4038-4058 | stale steer 替换 turn 的持久化/flush 约束。 | +| `scripts/regression.js` `runCodexAppRuntimeImageSteerRegression` | 4233-4365 | 后台/运行中 steer 真实 WebSocket 流程。 | +| `scripts/regression.js` `runCodexAppStaleRunningRegression` | 4367-4558 | app-server 缺失终态通知后的恢复及消息去重。 | +| `scripts/regression.js` `assertCodexAppChildToolRoutingContract` | 4867-5074 | child thread 与父卡片路由隔离。 | +| `scripts/regression.js` `assertCodexAppUnroutedNotificationRoutingContract` | 5076-5113 | thread 索引、负缓存、磁盘 adoption。 | +| `scripts/regression.js` `main --target` | 6032-6173 | 可增加 `gitea-workflow-codexapp` 独立目标,避免每次跑完整回归。 | +| `scripts/regression.js` Codex App 集成段 | 7239-7622 | mock app-server 下的 session、MCP config、collaboration、retry、Goal、runtime warning、工具调用。 | +| `scripts/mock-codex-app-server.js` `ensureThread`/`completeMcpToolTurn`/`startTurn`/`handleRequest` | 91-114、670-737、863-1023、1066-1250 | 可扩展为断言 gitea MCP 配置、thread/start/resume 和 turn/completed。 | +| `package.json` `regression` script | 8 | 统一执行 `node scripts/regression.js`。 | + +建议新增 mock 行为:在 `completeMcpToolTurn` 输出 gitea server 的 `type/command/args/env`(token 脱敏),在 `thread/start`/`thread/resume` 记录 config fingerprint;新增回归目标仅断言协议形状和状态机,不调用真实 Gitea。 + +## 当前验证结果 + +- `node scripts/regression.js --target codexapp-retry-runtime`:通过。 +- `node scripts/regression.js --target codexapp-unrouted-routing`:通过。 +- `node scripts/regression.js --target codexapp-stale-running`:通过。 +- 本任务没有修改 `server.js`、`lib/` 或 `public/`;仅新增本分析文件(以及临时计划 CSV,完成后删除)。校验时工作区已存在其他并行任务对 `server.js`/`lib` 的改动,本任务未触碰、未回退这些改动。 diff --git a/.planning/2026-08-23-gitea-workflow/plan-review.md b/.planning/2026-08-23-gitea-workflow/plan-review.md new file mode 100644 index 0000000..3cfba6b --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/plan-review.md @@ -0,0 +1,134 @@ +# Gitea Workflow 实施计划审查 + +审查日期:2026-08-24 +审查范围:`task_plan.md`、`findings.md`、`Gitea Workflow TO DO list.csv` 及用户目标“完整实现 Gitea Webhook → Codex App 持久会话 → 官方 gitea-mcp → Gitea 回执的可靠工作流,并提供管理页面、队列、恢复、测试和文档”。本审查只产生本文件,不修改业务代码或其它计划文件。 + +## 结论 + +当前计划在“阶段主题”层面与 CSV 基本一致,但还不能作为可直接执行和可验收的实施基线。建议先补齐 Phase 1 的协议、状态机、数据模型和安全设计,再进入实现;否则最容易出现“功能链路能跑一次,但重启、重复 Webhook、回执失败或并发时不可靠”的假完成。 + +建议结论:**有条件通过,暂不进入核心编码**。以下 P0 项未关闭前,不应把 CSV 中的核心实现项标记为 DONE。 + +## 计划与 CSV 一致性 + +| 计划范围 | 对应 CSV | 一致性 | 审查意见 | +|---|---|---|---| +| Phase 1 需求、协议、设计(`task_plan.md:13-18`) | 1、2 | 基本一致 | CSV 第 1 项为 `IN_PROGRESS`,与计划“进行中”一致;但“已完成边界汇总”和“设计文档尚在编写”的完成定义没有拆开。 | +| Phase 2 状态、Webhook、工作区(`task_plan.md:20-25`) | 3、4 | 基本一致 | 计划把队列/恢复和工作区放在同一阶段,CSV 分成两项;缺少明确前置依赖、交付物和负责人。 | +| Phase 3 Codex App、MCP、回执(`task_plan.md:27-32`) | 5 | 基本一致 | CSV 将线程注入、监听、补触发、REST 兜底合成一项,无法分别验收可靠性。 | +| Phase 4 管理界面、测试(`task_plan.md:34-39`) | 6、7 | 基本一致 | 计划中的重启恢复、并发、自触发回归没有在 CSV 中单列,容易被“测试已补充”掩盖。 | +| Phase 5 整合、验证、交付(`task_plan.md:41-45`) | 8、9、10 | 一致 | 但“解决冲突”“协议形状检查”“文档/审计”均没有明确输出文件、命令和通过阈值。 | + +总体上,CSV 的 10 项与计划的 15 个子项不是一一映射关系。建议在计划和 CSV 中增加稳定的任务 ID(例如 P1-01、P2-02),并为每项写明 `交付物 / 依赖 / 验收命令或证据 / 负责人`;否则状态同步只能凭文字判断。 + +## P0 阻断项 + +### P0-1:Phase 1 没有可执行的退出条件 + +计划只写“核验协议”“完成 PRD、状态机、数据模型和安全设计”(`task_plan.md:16-17`),没有指定文档路径、协议版本、决策结果或评审证据。`findings.md` 目前只有研究结论,没有状态转移表、字段定义、接口契约或威胁模型。 + +进入 Phase 2 前至少应产出并评审: + +- Webhook 请求/响应和签名头的版本化协议矩阵;官方 `gitea-mcp` 版本、启动参数、stdio 生命周期和工具能力清单。 +- 任务/会话/回执状态机(状态、事件、合法转移、重试上限、超时、取消、死信、人工恢复)。 +- 持久化数据模型、唯一键、索引、迁移/版本策略和崩溃恢复规则。 +- 线程、任务、Webhook delivery、评论和回执之间的关联契约。 +- 安全威胁模型与密钥生命周期设计。 + +### P0-2:持久化方案无法证明并发安全和崩溃一致性 + +`findings.md:17` 仅指出现有 JSON 持久化或“新增独立存储模块”,但没有做出选择。队列、去重、状态转移、会话映射和回执确认若继续散落写 JSON,会遇到并发覆盖、半写文件、重复消费和重启丢失;这与目标中的“持久化、可靠、可恢复”直接冲突。 + +计划必须明确单一事实源及原子性边界:至少要定义 inbox(Webhook 去重)、任务状态、会话映射、outbox(评论/补发意图)如何在一次事务或等价原子操作中落盘;并定义锁、文件替换、fsync、备份/恢复和 schema 迁移。若采用 JSON,需证明单写者/进程锁和崩溃恢复;若不能证明,应在计划中选择具备事务语义的存储。 + +### P0-3:状态机、幂等和回执可靠性没有闭环 + +`findings.md:34-39` 给出了“先验签/去重/过滤”“最多一次隐藏补触发”“REST 兜底”的原则,但没有定义可判定的幂等键、评论标记、确认窗口、重复运行保护或失败终态。尤其“任务完成但没有回帖”时,如何区分 MCP 调用已成功但查询延迟、调用超时、网络重试导致重复评论,当前不可执行。 + +需要补充: + +- delivery、任务、turn、评论发送各自的唯一 ID 及跨表关联。 +- 评论内容中的稳定 run/attempt 标记或等价查询条件,保证 MCP、补触发和 REST 兜底不会重复发同一回执。 +- Gitea 查询的最终一致性轮询窗口、退避、最大次数和“未知”状态;未知不能直接当失败重跑。 +- 重试分类(网络/5xx、4xx 权限、超时、进程崩溃)、退避与死信/人工重放策略。 +- 重启时每个状态的恢复动作,特别是 `running`、`waiting_user`、`receipt_pending` 和 `cancel_requested`。 + +### P0-4:三方协议的真实形状和版本未锁定 + +`findings.md:19-25` 只记录官方仓库和大致参数,`task_plan.md:29-30` 也没有 app-server 方法、事件类型或 MCP 注入字段。没有固定版本和 mock 契约,无法验收“线程级注入”和“turn 状态监听”是否真的走原生协议。 + +应在 Phase 1 固化并纳入 mock/回归断言: + +- Gitea Webhook 事件类型、评论字段路径、签名算法/头、delivery ID、超时和 HTTP 返回语义。 +- Codex App `initialize/initialized`、能力探测、`thread/start`、线程恢复、turn 事件和取消/中止的确切参数形状。 +- `thread/start.config.mcp_servers.*` 的线程级 env 注入、stdio 子进程启动/退出、超时和错误事件;不得退回动态工具或进程级来源上下文。 +- 官方 `gitea-mcp` 版本/校验和、`GITEA_HOST` 与 token 注入方式、工具列表和权限边界。 + +### P0-5:全写权限下的安全边界不足 + +计划确认“开放官方 gitea-mcp 全部写权限”(`task_plan.md:63`),但安全设计只写 HMAC 和 Token 分离(`task_plan.md:62`)。这不足以支撑公网 Webhook 和自动接入。至少要覆盖重放、密钥轮换、日志脱敏、仓库/主机 allowlist、请求体大小和限流、路径穿越、命令注入、工作区权限、MCP 子进程隔离,以及管理页面的认证/授权/CSRF。 + +同时要明确 Bot 自评论过滤不能只依赖用户名:应定义稳定的 bot identity、来源 delivery/run 标记和异常情况下的 fail-closed 行为,避免自触发风暴(`findings.md:34-36`)。 + +### P0-6:跨模块写冲突和整合策略未规划 + +Phase 5 写“整合子任务改动并解决冲突”(`task_plan.md:43`),但没有任务分区、文件所有权、接口冻结点或整合顺序。预期热点很可能包括 `server.js`、配置/持久化模块、Codex App turn 路径、MCP 配置、路由和前端状态;多个实现项若直接改同一入口,会产生不可审计的隐式冲突。 + +进入实现前应把工作拆成不重叠边界(例如 domain/state、webhook/queue、workspace、app-server adapter、API/UI、tests/docs),先冻结共享接口,再按依赖顺序合并;禁止以“最后人工解决冲突”作为验收条件。 + +## P1 重要遗漏与可执行性问题 + +### 1. 队列与恢复 + +Phase 2 只列“状态、队列、去重和恢复”(`task_plan.md:22`),缺少全局并发上限为 2 的调度算法、公平性、每仓库串行锁的租约/心跳、进程崩溃后的 stale runner 判定、优雅停机、取消排队与中止 turn 的区别、超时、死信和人工重放。应为每一类恢复场景写出输入、状态变化和预期结果,并加入 kill/restart 测试。 + +### 2. Webhook 与自动接入 + +`findings.md:5-7` 未定义配置校验、首次接入的授权边界、仓库 URL/owner/repo 的规范化、默认分支和私有仓库权限失败处理。需要明确先快速返回 HTTP,再异步入队还是同步处理;验签失败、重复 delivery、非评论事件、非目标评论、Bot 自评论分别返回什么,避免 Gitea 重试风暴。 + +### 3. 工作区与 Git 认证 + +`findings.md:40` 的“脏工作区不 reset、不覆盖”是原则,不是行为契约。要决定脏状态时任务是失败、暂停、继续当前会话还是创建隔离 worktree;定义分支/ref 切换、fetch 冲突、仓库删除、磁盘不足、凭据泄露(命令行/日志/remote config)和清理策略。持久 checkout 与“同仓库串行”还需明确锁的持有范围。 + +### 4. Codex App 会话与 waiting_user + +计划确认会话键(`task_plan.md:59`)和固定 `codexapp + yolo`(`task_plan.md:58`),但没有说明首次建线程、重启后的线程恢复、线程不存在/过期、app-server 断线、turn 超时及取消后的映射。`waiting_user`(`task_plan.md:31`、`task_plan.md:64`)没有定义由哪类 Gitea 评论恢复、是否阻塞同仓库、超时/过期和最大连续轮数;“连续评论投递”也没有顺序和幂等规则。 + +### 5. 管理 API 与前端 + +Phase 4 只写“实现管理 API 和前端页面”(`task_plan.md:36`),没有端点契约、权限角色、敏感配置展示规则、暂停/停用/取消/中止的并发控制、审计记录格式、分页过滤、实时刷新和错误提示。管理页面不能成为绕过队列状态机的第二套写入口;所有操作应复用同一领域命令和审计链。 + +### 6. 测试与验收证据 + +CSV 第 7、9 项虽覆盖“测试、构建、协议验收”,但没有测试矩阵、通过阈值或运行命令。至少应覆盖:重复/乱序 Webhook、伪造/重放签名、Bot 自触发、首次接入、同仓库并发、不同仓库并发上限、脏工作区、clone/fetch 失败、app-server 断线、MCP 缺失/工具报错、turn 中止、waiting_user、MCP 成功但查询延迟、补触发成功/失败、REST 兜底、进程 kill 后恢复、密钥轮换和管理 API 未授权。 + +协议测试不能只断言“调用成功”,还要断言真实参数形状、线程级 env、没有重复顶层字段、调用的是 MCP 工具而非动态工具,并保存 mock 输入/输出作为审计证据。应明确单元/集成/E2E 的最小通过标准和 CI/本地命令。 + +### 7. 运维、可观测性与交付 + +用户目标包含队列、恢复和文档,但计划未列指标、结构化日志、关联 ID、告警、数据保留、备份恢复、升级/回滚、配置校验和运行手册。Phase 5 的“文档、审计和最终交付”(`task_plan.md:45`)应明确至少包括架构/状态机、部署配置、Webhook 配置、密钥轮换、故障处理、恢复/重放、权限说明和已知限制。 + +## 建议的执行顺序与退出条件 + +1. **先完成 Phase 1 冻结契约**:补齐上述协议矩阵、状态机、schema/迁移、领域命令、错误/重试表、安全威胁模型和 mock fixture;每项有文档路径和评审人。 +2. **再实现持久化领域核心**:先落地 inbox、任务、会话、outbox、审计的原子状态转移和恢复算法,再接 Webhook/工作区,避免入口先写出不可恢复的副作用。 +3. **接入 Codex App 与 gitea-mcp**:以固定版本和协议 mock 验证线程级配置、turn 事件、取消/重连;所有回执发送经过幂等 outbox。 +4. **最后接 API/UI 和故障测试**:API/UI 只发领域命令;并发、重启、自触发和回执异常测试必须在标记 Phase 4 完成前通过。 +5. **Phase 5 交付门槛**:构建、静态检查、全测试和协议形状断言均有可复现命令;文档、审计样例、恢复演练记录和回滚方案齐全。 + +推荐把每个阶段的状态改成“未开始/进行中/阻塞/完成”,并在完成条件中引用证据文件或测试名称,而不是只保留勾选框。 + +## 最小验收清单(对应用户目标) + +- Webhook:合法评论可入队;伪造、重放、重复 delivery、非目标事件和 Bot 自评论均按契约处理。 +- 持久会话:会话键稳定;重启后任务、锁、turn 和 waiting_user 状态可恢复且不重复执行。 +- 官方 MCP:固定版本、线程级配置、凭据不落盘/不泄露;读写工具调用可被 mock 和审计验证。 +- 回执:成功评论可确认;未知状态不会盲目重跑;只允许一次补触发,之后 REST 兜底仍幂等。 +- 队列/并发:同仓库串行、不同仓库并行、全局上限 2 可证明;暂停、取消、终止和死信可操作。 +- 管理面:认证授权、审计、状态查询和运维操作与领域状态机一致,不暴露 token/secret。 +- 可靠性测试:覆盖崩溃恢复、并发、网络/权限/协议失败和自触发回归,并有可重复命令与结果。 +- 文档交付:架构、配置、部署、密钥、故障恢复、重放、回滚和已知限制齐全。 + +## 审查结论摘要 + +CSV 与计划没有明显的宏观漏项,但粒度、状态和验收证据不一致;当前最大的风险不是缺少页面或接口,而是持久化原子性、状态机幂等、三方协议形状和全写权限安全边界尚未冻结。补齐 P0 并将其转成带 ID/依赖/证据的任务后,计划才具备可靠实施条件。 diff --git a/.planning/2026-08-23-gitea-workflow/progress.md b/.planning/2026-08-23-gitea-workflow/progress.md new file mode 100644 index 0000000..6dff3db --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/progress.md @@ -0,0 +1,97 @@ +# Gitea Workflow 进度 + +## 2026-08-23 + +### 当前阶段:需求、协议与架构文档化 + +- 已完成用户需求 grilling 对齐。 +- 已确认 cc-web 本地会话、Codex App、MCP 注入和 turn 完成代码入口。 +- 已确认官方 gitea-mcp 的 stdio、环境变量和工具覆盖。 +- 已创建主实施计划,准备并行派发文档与协议核验子任务。 + +### 文件 + +- `.planning/2026-08-23-gitea-workflow/task_plan.md` +- `.planning/2026-08-23-gitea-workflow/findings.md` +- `.planning/2026-08-23-gitea-workflow/progress.md` + +### 测试 + +| 测试 | 结果 | +|---|---| +| 当前 git status | 工作区初始干净 | +| codebase-memory 索引 | ready | + +## 2026-08-24 + +### 当前阶段:集成验收与交付 + +- 已落地 `lib/gitea-workflow-domain.js`、`store`、`queue`、`service`,统一由 `server.js` 挂接。 +- 已落地 Webhook HMAC/mention/delivery 去重、HTTPS 工作区 clone/fetch、临时 Git 凭据和脏目录阻塞。 +- 已落地 Codex App 线程级官方 `gitea-mcp`、turn 标记、回执查询、一次只补发回执和 REST 兜底。 +- 已落地配置、仓库、任务、审计、暂停/恢复/取消/中止管理 API 与前端页面。 +- 专项测试全部通过:Webhook 回归 9 场景、核心 7 项、工作区、Codex、管理页面。 +- `npm run regression` 已在 60 秒门限内完成并输出 `Regression checks passed.`;此前的长时观察记录已由最终回归结果覆盖。另已通过 `codexapp-unrouted-routing`、`composer-slash-routing`、`codexapp-stale-running` 目标回归。 +- 当前主机未安装 `gitea-mcp` 可执行文件,也未配置真实 Gitea host/token;真实出站 MCP/REST 回帖仍需部署后 smoke test。 + +### 2026-08-24 主任务终审补强 + +- 修正隐藏回执补发轮次的 turn 标识校验:允许初始预分配标识与历史补发标识集合,避免 + Agent 已回帖但因 Codex 实际 turnId 变化被误判为缺失。 +- 查询 Gitea 回帖状态为 `unknown` 时改为保守失败:不自动补发/REST 覆盖,先发送状态 + 告警并标记 `failed_reply`,与 CODEX-INTEGRATION、PRD、ARCHITECTURE 统一。 +- 工作区成功/脏目录状态回写 Workflow Store;管理页补齐仓库工作区、队列数、活动任务、 + 脏目录原因和审计日志镜像;配置接口校验 Gitea 地址并保留 0600 凭据文件。 +- Gitea 管理 JS/CSS 使用独立缓存版本,避免只修改管理资源时命中旧缓存。 +- 修正管理停用仓库后的规范化 Webhook 路径:`enqueueNormalizedTask` 现在返回明确 + `ok`,对已停用仓库拒绝入队并写入审计,避免通过独立 receiver 绕过停用控制。 +- 管理页保存的并发上限现会在重启后被 Workflow 服务读取;环境变量凭据只同步“已配置” + 标志,不把明文 Secret 写入管理状态。 +- 工作区脏目录现在记录 `dirtySessionKey`:同一 Issue/PR 会话后续 turn 可安全沿用现场, + 跳过 fetch/checkout;其他讨论仍进入 `blocked_workspace`,且每轮结束会重新探测并在清洁后 + 自动清除脏标记。 +- Webhook 对完整 `ccweb-gitea` 隐藏标识做前置过滤,即使 Gitea 事件缺少作者字段也不会 + 触发自循环。 +- 队列的工作区阻塞、失败、取消和中止终态现在统一发送 Gitea 状态评论;REST 兜底成功 + 会单独标记“REST 兜底回帖”,避免用户只看到初始排队消息。 +- 管理配置更新和控制操作均强制要求 `reason`,并在接口层返回 `reason_required`,保证审计 + 记录不会出现无原因的人工运维动作。 + +### 终审验证 + +| 测试 | 结果 | +|---|---| +| Gitea 专项 5 脚本 | 全部通过 | +| `node --check` + `git diff --check` | 通过 | +| `timeout 60s npm run regression` | `Regression checks passed.` | +| 隔离服务启动/HTTP 冒烟 | 根页面 200;未认证管理 API 401;未配置 Secret 的 Webhook 503 | + +### 主任务验收补强 + +- 复核子任务实际代码、管理页面/API 挂接、文档和持久化边界;未发现重复路由或 + 覆盖核心模块的问题。 +- 修正 `server.js` 启动时环境配置同步:只有实际提供的环境字段才覆盖管理镜像, + 避免空的 `CC_WEB_GITEA_WORKSPACE_ROOT` 或并发字段覆盖页面已保存配置。 +- 重新通过五个 Gitea 专项脚本、`node --check`、`git diff --check` 和 + `timeout 60s npm run regression`。 +- 隔离实例 HTTP 冒烟再次通过:根页面 200、未认证管理 API 401、未配置 Secret + 的 Webhook 503;未触碰当前 PM2 进程。 +- 追加运行时收敛保护:初始/补发 Codex turn 启动被拒绝时立即清理 waiter 并进入 + 回帖失败/REST 兜底路径;任务持久化 `turnState/turnStartedAt/turnUpdatedAt`, + 管理页可看到更准确的 Turn 状态。 +- 工作区校验新增 Gitea host 白名单,阻止 Webhook 携带跨实例 clone URL;队列恢复 + 和入队按评论创建时间(缺失时按接收/创建时间)排序,满足同讨论串顺序处理。 +- 上述补强后核心、Webhook、管理专项测试与语法/差异检查均再次通过。 +- 启动配置镜像现在会读取 Secret 文件实际内容后再标记“已配置”,不存在或空文件 + 不会造成管理页虚报凭据可用;隔离 HTTP 冒烟仍通过。 +- 对“每个 @ 必须有回执”再补强:停用仓库或 Webhook 异步入队异常会发送带任务标识的 + Gitea 状态回帖,而不是只写本地日志。 +- 澄清交互采用独立 `waiting_user` 隐藏回执标识;Agent 提问不会被误判为最终回帖 + 缺失,也不会触发重复修改型 reply retry。未配置 Bot Token 时 Webhook 直接 503, + 防止接受无法回帖的任务。 +- 最终专项脚本、语法检查、差异检查和完整 regression 均通过。 +- 复核“文档与集成测试”子任务回传:7 个 fixture 均可解析,独立脚本未加载 + `server.js`,9/9 场景覆盖验签、过滤、恢复、回帖补偿、MCP 和安全边界;未发现 + 该子任务修改 `server.js` 的证据。 +- 主任务随后补上状态评论持久化幂等键(`taskId:status`),使 ARCHITECTURE/PRD + 中的评论幂等契约与运行实现一致;专项测试继续通过。 diff --git a/.planning/2026-08-23-gitea-workflow/protocol-research.md b/.planning/2026-08-23-gitea-workflow/protocol-research.md new file mode 100644 index 0000000..b605c50 --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/protocol-research.md @@ -0,0 +1,340 @@ +# Gitea Webhook 与官方 gitea-mcp 协议研究 + +> 研究性质:只读核验;未修改 `server.js`、`lib/`、`public/`。 +> +> 核验时间:2026-08-24(Asia/Shanghai)。 +> +> 版本口径:Gitea 文档采用 versioned 1.24 文档;Gitea 源码采用 GitHub +> `go-gitea/gitea` 提交 `4852091e851060d2233fcbf789727b1c5dba4bfc` +> (2026-08-23);`gitea-mcp` 当前 `main` 为 +> `815a0e26aab26c2df0963f7fca172a6f5b4a2e41`(2026-08-23),最新正式发行版 +> 为 `v1.6.0`,其提交为 `290d06b40b9fd225e53ec9fa4be1431dc787d0ff` +> (2026-07-30)。当前 `main` 已切换到官方 MCP Go SDK,和 v1.6.0 的依赖/实现 +> 不完全相同;部署时应固定一个已验证的二进制或提交,不要隐式跟随 `main`。 + +## 1. 官方来源 + +### Gitea + +- Webhook 文档(1.24): + + (渲染页:)。 +- API 使用说明(1.24): + 。 +- API 1.24.7 “Add a comment to an issue”: + 。 +- Webhook 发送逻辑: + +- Webhook payload 定义: + +- Issue/PR/Comment 结构: + 、 + 、 + 。 +- 官方集成测试(普通 Issue/PR 评论): + + (`Test_WebhookIssueComment`、`Test_WebhookPullRequestComment`)。 + +### gitea-mcp + +- 仓库:。 +- 当前 README(启动、工具表、HTTP 说明): + 。 +- 当前启动参数与环境变量源码: + 。 +- 当前 MCP/HTTP 运行与限制: + 。 +- 当前 Gitea API 客户端、超时、重定向和认证: + 、 + 。 +- 最新发行版:。 + +## 2. Gitea Webhook 协议 + +### 2.1 请求、事件头和签名 + +Gitea Webhook 通常向目标 URL 发送 JSON `POST`。官方文档的示例明确要求 +`POST`、`Content-Type: application/json`,并给出以下头: + +```text +X-Gitea-Delivery: +X-Gitea-Event: +X-Gitea-Signature: +``` + +当前 Gitea 源码 `services/webhook/deliver.go` 还发送: + +```text +X-Gitea-Event-Type: +X-Gitea-Hook-Installation-Target-Type: +X-Gogs-* # 兼容 Gogs +X-Hub-Signature: sha1= +X-Hub-Signature-256: sha256= +X-GitHub-* # 兼容 GitHub +``` + +事件名有两个层次,不能混淆: + +- Issue 普通评论:`X-Gitea-Event: issue_comment`,内部类型也是 + `issue_comment`。 +- PR 普通评论:`X-Gitea-Event: issue_comment`,但当前源码的 + `X-Gitea-Event-Type` 为 `pull_request_comment`。 + +因此接收端应优先使用 `X-Gitea-Event` 判断大类,再结合 payload 的 +`is_pull`/`pull_request` 或 `X-Gitea-Event-Type` 区分 Issue 与 PR;不要只依赖 +某个新版本才出现的扩展头。 + +配置 Secret 后,当前发送端对**原始请求 body 字节**计算: + +```text +hex(HMAC-SHA256(secret, body)) +``` + +`X-Gitea-Signature` 是不带 `sha256=` 前缀的小写十六进制串;源码同时生成 +GitHub 兼容的 `sha256=` 形式。接收端必须在 JSON 解析或重新格式化之前读取 +原始 body,并使用常量时间比较(如 Node `timingSafeEqual`)。文档中的 PHP 示例 +先 `trim` 后计算,只是示例代码;对本项目应以实际收到的 body 字节为准,不能依赖 +JSON 等价性或 `trim` 后再验签。 + +Webhook payload 中历史 `secret` 字段自 Gitea 1.13 起已弃用,文档标注将移除; +不能把 payload 的 `secret` 当作认证依据。应使用独立配置的 HMAC Secret。 + +**实现约束:** Webhook 路由应限制 body 大小和 `application/json`,先验签、再解析, +验签失败返回 4xx;合法请求在持久化 delivery 与任务后尽快返回 2xx,不能把整个 +Codex turn 放在 HTTP 请求内同步执行。 + +### 2.2 Delivery UUID 与去重 + +`X-Gitea-Delivery` 是 Gitea 为每次发送生成的 UUID。官方文档介绍了 Webhook +设置页的 Recent Deliveries,但没有规定接收方的去重窗口、重试次数、重试退避、 +投递顺序或“同一 delivery 是否只会出现一次”。Gitea 源码的 `HookTask.UUID` 在 +发送端数据库中唯一,但那是 Gitea 自己的出站任务记录,不是对外接收方的幂等协议。 + +**本项目必须自行实现:** + +1. 在验签之后、入队之前,以 `(Gitea 实例标识, X-Gitea-Delivery)` 建立持久唯一键。 +2. 采用原子“插入/已存在”语义;重复请求直接返回成功,不重复 clone、启动 turn 或 + 回帖。 +3. delivery 记录应包含原始 headers/body 摘要、接收时间、解析结果和最终任务状态, + 便于审计与恢复。 +4. 由于官方未承诺顺序,队列按仓库串行化;不要按 delivery UUID 推断先后。 + +### 2.3 Issue/PR 普通评论 payload + +当前 Gitea `modules/structs/hook.go` 定义的 `IssueCommentPayload` 为: + +```json +{ + "action": "created|edited|deleted", + "issue": { "number": 123, "title": "...", "body": "...", "user": {}, "repository": {} }, + "pull_request": { "number": 123, "title": "...", "user": {}, "base": {}, "head": {} }, + "comment": { + "id": 456, + "html_url": "...", + "pull_request_url": "...", + "issue_url": "...", + "user": {}, + "body": "评论正文", + "assets": [], + "created_at": "...", + "updated_at": "..." + }, + "changes": { "body": { "from": "旧正文" } }, + "repository": { "id": 1, "name": "repo", "full_name": "owner/repo", "clone_url": "..." }, + "sender": { "id": 2, "login": "user" }, + "is_pull": true +} +``` + +字段语义: + +- `action=created` 是新增普通评论;`edited`/`deleted` 是评论编辑/删除。 +- Issue 评论:`is_pull=false`,通常没有 `pull_request` 对象。 +- PR 普通评论:`is_pull=true`,当前官方测试确认 `pull_request_comment` 事件的 + payload 类型仍是 `IssueCommentPayload`,并带 `pull_request` 对象;评论入口仍是 + PR 所对应的 Issue number。 +- `comment.body` 是 Markdown 文本;`comment.id` 是回查/审计评论的稳定标识。 +- `changes.body.from` 只对编辑动作有意义。 +- `repository.full_name` 是最适合用作工作流仓库键的字段,但仍应做格式校验并拒绝 + 路径穿越/不合法 owner 或 repo。 + +**本项目触发过滤建议:** MVP 只消费 `action=created`,并在 `comment.body` 中按 +明确规则识别 `@ccweb-bot`;编辑/删除不应重新触发任务。还必须在入队前过滤 +`sender` 为本项目 Bot 的自评论,否则“最终回帖 → Webhook → 新任务”会形成回路。 +Bot 判断应使用稳定用户 ID/登录名配置,而不是只匹配正文。 + +## 3. Gitea REST/API 回执协议 + +### 3.1 普通评论写入 + +官方 API 1.24 文档的新增评论接口为: + +```text +POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments +Authorization: token +Content-Type: application/json + +{"body":"评论正文"} +``` + +返回的是 Comment 对象。PR 普通评论同样使用该 Issue 评论接口(`index` 为 PR +编号);PR review/diff inline comment 是另一套 review API,不能用来代替普通状态 +回帖。`gitea-mcp` 的 `issue_write` 中 `method=add_comment` 正是这个普通评论路径, +而 `pull_request_review_write` 的 `reply_comment` 针对 review comment 线程。 + +Gitea API 文档说明支持 `Authorization: token ...`,OAuth token 也接受 +`Authorization: bearer ...`;URL query token 虽受支持,但本项目禁止把 token 放在 +URL、日志或评论中。Bot Token 与 Webhook HMAC Secret 必须分离。 + +### 3.2 分页、响应上限和错误 + +- Gitea 默认 `[api].MAX_RESPONSE_ITEMS=50`;可由实例配置改变。 +- API 使用 `page` 与 `limit` 分页,并在有更多页时返回 `Link`;还可返回 + `x-total-count`。 +- `gitea-mcp` 对外参数多使用 `page`/`per_page`,SDK/API 请求会被 Gitea 的 + `MAX_RESPONSE_ITEMS` 静默截断;不能把 `per_page` 当作无上限。 +- Gitea 文档没有给出适用于所有实例的固定速率限制数字。反向代理、云服务或实例 + 配置可能另行限流;实现应对 429/5xx 做有上限的退避,并记录 `Retry-After`,不要 + 假设一定存在该头。 +- `gitea-mcp` 源码的 REST helper 使用 60 秒 HTTP client timeout;没有看到自动 + 重试逻辑。工具调用失败通常编码为 MCP `tools/call` 结果的 `isError=true`,而 + malformed request/server fault 才是 JSON-RPC error。Codex App 监听时必须同时处理 + 这两类失败。 + +**实现影响:** 正常回执优先由 MCP `issue_write/add_comment` 发送;cc-web 必须通过 + API 查询或 Webhook 回流确认 comment ID/正文确实出现。若 turn 完成但没有真实回帖, + 只允许同一会话发起一次“只补发回执、不重复修改”的 turn;仍失败时才用上面的 REST + endpoint 兜底,并把 HTTP 状态、响应摘要和是否产生 comment ID 写入审计。 + +## 4. 官方 gitea-mcp(stdio) + +### 4.1 启动与配置 + +官方 README 给出的 stdio 形状是: + +```bash +gitea-mcp -t stdio -H https://gitea.example.com +``` + +通过环境传 Token(推荐,避免出现在进程列表): + +```text +GITEA_HOST=https://gitea.example.com +GITEA_ACCESS_TOKEN= +``` + +当前 `cmd/cmd.go` 支持的关键参数/环境变量: + +| 用途 | 参数 | 环境变量/默认值 | +|---|---|---| +| 传输 | `-t`, `--transport` | `MCP_MODE`;默认 `stdio` | +| Gitea 地址 | `-H`, `--host` | `GITEA_HOST`;空时 `https://gitea.com` | +| Token | `-T`, `--token` | `GITEA_ACCESS_TOKEN`;也支持 `GITEA_ACCESS_TOKEN_FILE` | +| 只读 | `-r`, `--read-only` | `GITEA_READONLY=true` | +| 工具白名单 | `-O`, `--tools` | `GITEA_TOOLS` | +| Scope 白名单 | `-S`, `--scope` | `GITEA_SCOPES` | +| 调试/TLS | `-d`, `-k` | `GITEA_DEBUG=true`、`GITEA_INSECURE=true` | +| HTTP 备用传输 | `-b`, `-p` | 默认 bind 全接口、port `8080` | +| 附件内联上限 | `--max-inline-attachment-bytes` | `GITEA_MAX_INLINE_ATTACHMENT_BYTES`;默认 5 MiB | + +参数优先级以源码为准:显式 token 参数优先于环境;没有显式 token 时读取 +`GITEA_ACCESS_TOKEN`,再尝试 `GITEA_ACCESS_TOKEN_FILE`。项目应使用固定版本的本地 +二进制作为 stdio command,并将 `GITEA_HOST`/`GITEA_ACCESS_TOKEN` 放在线程级 MCP +进程环境,不要放到全局长驻 app-server 环境或命令行 `-T`。当前 `main` 的 `go.mod` +要求 Go 1.26.0/toolchain 1.26.6;因此不建议在本项目运行时用未锁定的 +`go run ...@latest`(本机旧 Go 或上游变更都会导致不可复现)。 + +stdio 模式日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`(目录 0700、滚动保留);源码 +只在 HTTP 模式把日志复制到 stdout。不要把任何调试输出写入 stdio 的协议 stdout, +否则会污染 MCP JSON-RPC 流。 + +建议的线程级配置形状(概念示例): + +```json +{ + "command": "/opt/cc-web/bin/gitea-mcp-v1.6.0", + "args": ["-t", "stdio", "-H", "https://gitea.example.com"], + "env": { "GITEA_ACCESS_TOKEN": "<线程专用 Bot Token>" } +} +``` + +### 4.2 工具和写操作 + +当前 README 工具表共 54 个工具;未指定 `-r`、`-S`、`-O` 时全部暴露。`-r` 会隐藏 +所有 Write 工具;`-S` 与 `-O` 是并集筛选。动作型工具通过 `method` 参数再选择 +具体操作。 + +主要只读工具包括:用户/组织(`get_me`、`get_user_orgs`)、搜索(`search_*`)、 +通知读取、标签/里程碑/wiki/计时/包读取、Issue 列表/读取/附件读取、PR 列表/读取、 +Actions 配置/运行读取、仓库列表/tree、文件/目录读取、分支/标签/提交/Release 读取、 +版本查询。 + +Write 工具及其源码声明的实际操作如下: + +| 工具 | 写操作 | +|---|---| +| `notification_write` | `mark_read`、`mark_all_read` | +| `label_write` | repo/org label 的 create、edit、delete | +| `milestone_write` | create、update/edit、delete | +| `wiki_write` | create、update、delete 页面 | +| `timetracking_write` | start/stop/delete stopwatch、add/delete time | +| `package_write` | 删除 package version(不可逆) | +| `issue_write` | create、update、`add_comment`、`edit_comment`、add/remove/replace/clear labels | +| `pull_request_write` | create、update、close、reopen、merge、update_branch、add/remove reviewers | +| `pull_request_review_write` | create/submit/delete/dismiss review、`reply_comment`、resolve/unresolve thread | +| `actions_config_write` | Actions secret/variable 的 upsert/create/update/delete(repo/org) | +| `actions_run_write` | dispatch、cancel、rerun workflow run | +| `create_repo`/`fork_repo` | 创建/派生仓库 | +| `create_or_update_file`/`delete_file` | 创建、更新、删除仓库文件 | +| `create_branch`/`delete_branch` | 创建、删除分支 | +| `create_tag`/`delete_tag` | 创建、删除 tag | +| `create_release`/`delete_release` | 创建、删除 Release | + +这远超“回帖”所需权限。当前产品决策明确要求 MVP 开放官方 gitea-mcp 全部写权限, +因此必须同时具备全局暂停、仓库停用、排队取消、turn 中止和完整审计;不能把“工具 +调用成功”当作安全授权。生产环境仍应为 Bot Token 配置最小可用仓库/组织权限,并在 +管理面明确显示高风险工具可用。 + +### 4.3 HTTP 备用模式的限制(不是本 MVP 主路径) + +当前 `main` 的 HTTP transport 使用 `/mcp`,只接受 POST,启用 Origin 校验,且始终 +无状态:不使用 `Mcp-Session-Id`、独立 SSE 或 `Last-Event-ID` 恢复。反向代理必须原样 +转发 `Mcp-Protocol-Version`、`Mcp-Method`、`Mcp-Name`。HTTP 请求可用 +`Authorization: Bearer ` 或 `Authorization: token `,这是逐请求的 +Gitea credential passthrough,不是 MCP OAuth。HTTP MCP 默认请求体上限由源码提高到 +32 MiB。若未来不用 stdio 而改 HTTP,必须额外处理这些协议与反代约束。 + +## 5. 对 cc-web 工作流的落地影响 + +1. **Webhook 接收顺序固定为:** 限制请求 → 读取原始 body → HMAC 验签 → 校验 + `X-Gitea-Delivery`/事件头 → JSON 解析 → Bot 自评论过滤 → `action=created` 与 + `@ccweb-bot` 过滤 → 持久化唯一 delivery → 快速 2xx → 入队。 +2. **会话键:** 使用 `gitea + repository.full_name + issue/PR 类型 + number`;PR + 普通评论仍使用 `issue_write/add_comment`,不是 review reply。 +3. **回执确认:** MCP 最终输出不等于 Gitea 已写入;必须查询评论列表/回流 Webhook + 确认正文和 comment ID。缺失时最多一次隐藏补发 turn,最后 REST 兜底。 +4. **凭据隔离:** HMAC Secret 只用于入站验签,Bot Token 只用于 MCP/REST/Git;Token + 通过线程级 env 注入,禁止写入 remote URL、任务正文、日志和 URL query。 +5. **并发和恢复:** delivery 去重、任务状态、仓库锁和回执审计必须持久化;同仓库 + 串行、不同仓库按全局上限并行。进程重启后 queued 恢复,running 按既定最多一次重试, + waiting_user 保留。 +6. **API 稳定性:** 对 429、5xx、网络超时做有限退避;尊重 Gitea 的分页上限和 + `MAX_RESPONSE_ITEMS`;MCP 的 `isError=true` 与 JSON-RPC error 均视为失败路径。 + +## 6. 不确定项与需集成验收的内容 + +- 官方文档没有承诺 Webhook 的重试次数、退避策略、投递顺序或接收端去重语义;需在 + 实际 Gitea 版本通过失败投递/Recent Deliveries 做黑盒验收。 +- `X-Gitea-Event-Type`、部分 payload 字段和旧版本兼容头可能随 Gitea 版本变化;接收 + 端必须允许缺失扩展头,并以稳定字段/大类事件降级。 +- API 速率限制取决于 Gitea 版本、实例配置、云服务或反向代理;当前官方资料未提供 + 可直接套用的全局数值。 +- Token 的精确 scope/仓库权限由目标 Gitea 实例决定;需要用目标 Bot Token 实测 + `issue_write/add_comment`、读取评论、读取 PR/文件和必要的仓库操作。 +- 当前 `gitea-mcp` `main` 要求 Go 1.26,最新发行版与 `main` 的 MCP SDK/HTTP 实现 + 不同;必须在 CI/部署机锁定并做 stdio `initialize`、`tools/list`、真实 + `issue_write` 和错误返回回归。 +- Gitea Webhook 文档示例 PHP 使用 `trim` 后验签,与当前发送源码“对原始 body 签名” + 的实现细节存在示例层面的歧义;本项目应以原始字节验签,并用目标实例实际 delivery + 样本校验。 + diff --git a/.planning/2026-08-23-gitea-workflow/task_plan.md b/.planning/2026-08-23-gitea-workflow/task_plan.md new file mode 100644 index 0000000..74626f3 --- /dev/null +++ b/.planning/2026-08-23-gitea-workflow/task_plan.md @@ -0,0 +1,71 @@ +# Gitea Workflow 持久化实施计划 + +## 目标 + +在 cc-web 内完整落地 Gitea Webhook → 持久 Codex App 会话 → 官方 gitea-mcp → Gitea 回执的工作流,并提供可恢复的队列、工作区和管理界面。 + +## 当前阶段 + +Phase 5:集成验收与交付 + +## 阶段 + +### Phase 1:需求与协议确认 + +- [x] 汇总用户已确认的产品边界 +- [x] 核验官方 Gitea Webhook 与 gitea-mcp 协议 +- [x] 完成 PRD、状态机、数据模型和安全设计 +- 状态:已完成 + +### Phase 2:核心工作流实现 + +- [x] 实现配置、状态、队列、去重和恢复 +- [x] 实现 Webhook 验签、事件过滤和自动接入 +- [x] 实现 clone/fetch、仓库锁和工作区状态 +- 状态:已完成 + +### Phase 3:Codex App 与回执集成 + +- [x] 注入线程级 gitea-mcp 配置 +- [x] 实现 turn 状态监听、回执确认、补触发和 REST 兜底 +- [x] 实现 waiting_user 与连续评论投递 +- 状态:已完成 + +### Phase 4:管理界面与测试 + +- [x] 实现工作流管理 API 和前端页面 +- [x] 补充单元、回归、协议 mock 和安全测试 +- [x] 完成重启恢复、并发和自触发回归 +- 状态:已完成 + +### Phase 5:集成验收与交付 + +- [x] 整合子任务改动并解决冲突 +- [x] 运行测试、构建和真实协议形状检查 +- [x] 完成文档、审计和最终交付 +- 状态:已完成 + +## 已确认决策 + +| 决策 | 结论 | +|---|---| +| 触发 | Issue/PR 普通评论中的 `@ccweb-bot` | +| 实例 | MVP 单个 Gitea 实例,一个全局 Bot | +| 未登记仓库 | 首次合法触发自动接入并 clone | +| 工作区 | 全局根目录下每仓库持久 checkout | +| Git 认证 | HTTPS + Bot Token,临时凭据,不写 remote URL | +| 并发 | 同仓库串行,不同仓库并行,全局上限默认 2 | +| Agent | 固定 `codexapp` + `yolo` | +| 会话键 | `gitea + owner/repo + type + number` | +| 交互 | Gitea 主交互,cc-web 镜像和运维 | +| 回复 | 状态评论 + MCP 最终回复;缺失时补触发一次,最后 REST 兜底 | +| 安全 | HMAC Webhook Secret;Bot Token 与 Secret 分离 | +| MCP 权限 | 用户明确要求开放官方 gitea-mcp 全部写权限 | +| 恢复 | queued 恢复,waiting_user 保留,running 中断并最多重试一次 | +| 运维 | 全局暂停、仓库停用、取消排队、中止 turn、审计日志 | + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 暂无 | 1 | - | diff --git a/.planning/2026-08-24-gitea-codex-turn/findings.md b/.planning/2026-08-24-gitea-codex-turn/findings.md new file mode 100644 index 0000000..fc39615 --- /dev/null +++ b/.planning/2026-08-24-gitea-codex-turn/findings.md @@ -0,0 +1,15 @@ +# Gitea Codex 工作流实现发现 + +- `server.js` 的 `codexAppThreadConfig(session, options)` 最终将每项配置写为 `mcp_servers.`,所以独立适配器应返回与 app-server config 兼容的 `{type:'stdio', command, args, env}`。 +- `startCodexAppTurn()` 在 `thread/start`/`thread/resume` 后记录 `entry.threadId`,在 `turn/start` 返回后记录 `entry.turnId`;`handleCodexAppTurnComplete()` 是完成监听点。 +- 运行时事件可能从 `params.turnId`、`params.turn.id`、`params.item.turnId` 取 turnId;关联标记必须同时支持明文状态字段和 HTML 注释隐藏标识。 +- 官方 gitea-mcp 采用 stdio,启动参数为 `-t stdio -H `,token 通过线程级 env 注入;不应放到长驻 app-server 全局环境。 +- gitea-mcp 最终评论需要查询确认;查询需匹配资源、Bot 作者和 taskId/turnId/resourceKey 隐藏标识,未知状态不能直接重复执行。 +- waiting_user 新评论应复用原 `sessionKey/threadId`,但每条评论创建新 task/turn;补触发必须是同一 thread 的隐藏轮次,且最多一次。 + +## 实现接口 + +- `buildGiteaThreadConfig({cwd, host, accessToken})` 返回 `config['mcp_servers.gitea']`,内部为 `type=stdio`、`gitea-mcp -t stdio -H host` 和线程级 `GITEA_HOST/GITEA_ACCESS_TOKEN`。 +- `buildWorkflowMarker`/`parseWorkflowMarker` 使用 ``,缺少 taskId、turnId 或 resourceKey 时返回 null。 +- `createGiteaWorkflowCodex` 通过 `verifyReply/listComments/startTurn/sendRestReply` 依赖注入实现外部副作用;`unknown` 查询直接进入保守 `failed_reply`,不会自动补触发。 +- `server.js` 的 `codexAppThreadConfig` 同时兼容 `{host, accessToken}` 和已构建的 stdio config;`startCodexAppTurn`、`handleCodexAppTurnComplete` 提供 turn 生命周期薄挂接,既有 waiter 在完成时执行回执确认/补偿。 diff --git a/.planning/2026-08-24-gitea-codex-turn/progress.md b/.planning/2026-08-24-gitea-codex-turn/progress.md new file mode 100644 index 0000000..b78bffa --- /dev/null +++ b/.planning/2026-08-24-gitea-codex-turn/progress.md @@ -0,0 +1,22 @@ +# Gitea Codex 工作流实施进度 + +## 日志 + +- 2026-08-24:读取现有 Gitea Workflow 研究、PRD/ARCHITECTURE 和 Codex App 代码入口;确认本次只改独立模块、最小 server.js 挂接点、协议 mock 与单测。 +- 2026-08-24:完成代码入口定位;计划审查要求补齐隐藏标识、Bot 身份、回执三态、同线程单次补触发和 REST 终止条件,已纳入验收约束。 +- 2026-08-24:新增 `lib/gitea-workflow-codex.js`,实现线程级 stdio gitea-mcp 配置、版本化隐藏标记、turnId/threadId 提取、Bot 自评论过滤、waiting_user 连续评论、回执 confirmed/missing/unknown 和单次 reply_retry/REST 兜底。 +- 2026-08-24:`server.js` 增加独立模块 require、gitea MCP config 合并、turn started/completed 薄挂接,并让既有 Gitea waiter 在 turn 完成时 settle;查询未知状态不再自动重跑。 +- 2026-08-24:补充 server waiter 对预分配 workflow marker turnId 与实际 Codex turnId 的兼容匹配;首轮旧 marker 可确认,reply_retry 使用实际新 turnId。 +- 2026-08-24:新增 `scripts/gitea-workflow-codex-unit.js` 协议 mock/单测,覆盖配置形状、隐藏标记、Bot id/login、waiting_user 事件与同线程连续评论、单次补触发、REST 次数和 unknown 保守终态。 + +## 验证 + +| 命令 | 结果 | +|---|---| +| `node --check`(Codex 适配器、server.js、相关 Gitea 模块) | 通过 | +| `node scripts/gitea-workflow-codex-unit.js` | 通过 | +| `node scripts/gitea-webhook-regression.js` | 通过,9 场景 | +| `node scripts/gitea-webhook-workspace-unit.js` | 通过 | +| `node scripts/gitea-workflow-management-unit.js` | 通过 | +| `node scripts/gitea-workflow-core-unit.js` | 通过,6 项 | +| `timeout 60s npm run regression` | 未通过:既有 MCP 创建会话回归在等待 `background_done` 处超时(scripts/regression.js:6717),未出现本适配器异常;需后续单独修复该既有回归 | diff --git a/.planning/2026-08-24-gitea-codex-turn/task_plan.md b/.planning/2026-08-24-gitea-codex-turn/task_plan.md new file mode 100644 index 0000000..13e932b --- /dev/null +++ b/.planning/2026-08-24-gitea-codex-turn/task_plan.md @@ -0,0 +1,39 @@ +# Gitea Codex 工作流会话与回执实施计划 + +## 目标 + +新增独立的 Codex App/Gitea 工作流适配模块,覆盖线程级官方 gitea-mcp 注入、turnId 关联、waiting_user 连续评论、回执确认、一次补触发和 REST 兜底;以最小挂接点接入 server.js,并用协议 mock 与单测验收。 + +## 步骤 + +- [x] 定位现有 Codex App 线程、turn、MCP 配置和通知路由挂接点 +- [x] 冻结独立模块 API 与隐藏 turnId/Bot 自评论标记协议 +- [x] 实现线程级 gitea-mcp 配置、waiting_user 评论串联和回执状态机 +- [x] 增加 server.js 最小挂接示例与可复制的接入说明 +- [x] 编写协议 mock 和模块单元测试,覆盖补触发/REST 兜底 +- [x] 运行语法检查、单测和现有回归并记录结果 + +## 完成记录 + +- 线程级 `mcp_servers.gitea`、隐藏标识、Bot 过滤、waiting_user 连续评论、一次补发和 + REST 兜底已接入 `server.js`。 +- 回帖校验允许历史补发标识集合;查询 `unknown` 时保守失败,不自动重复写操作。 +- `node scripts/gitea-workflow-codex-unit.js`、Gitea 专项回归和全量 `npm run regression` + 均通过。 + +## 验收约束 + +- 隐藏标识采用版本化 HTML 注释,编码后的 payload 必须包含 `taskId`、`turnId` 和 `resourceKey`,解析失败时不得误判为本轮回执。 +- Bot 自评论优先按稳定 user id 判定;未配置 id 时才回退到大小写无关 login;带有效 ccweb 隐藏标识的评论始终不触发新任务。 +- 回执确认严格区分 `confirmed`、`missing` 和 `unknown`;查询异常为 `unknown`,禁止据此重复执行代码修改。 +- 正常回执未确认时最多创建一个 `reply_retry`,且必须复用原 `threadId`;补触发提示词只允许补发回执,不允许修改代码。 +- 补触发后仍非 `confirmed` 才允许 REST 兜底;REST 成功或最终失败后不得再次补触发。 +- gitea-mcp 只能出现在 `thread/start.config.mcp_servers.gitea`,host/token 经该线程的 args/env 注入,禁止写入 app-server 进程全局环境。 +- `server.js` 仅增加 require、配置合并、turn started/completed/user-input 信号等薄挂接;工作流逻辑必须留在独立模块。 +- 单测必须断言线程配置形状、标记往返/Bot 过滤、waiting_user 同线程投递、查询异常、单次补触发和 REST 兜底次数。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| planning-with-files catchup 脚本路径不存在 | 1 | 记录后改为直接读取现有规划文件并继续 | diff --git a/.planning/2026-08-24-gitea-mcp-failure/findings.md b/.planning/2026-08-24-gitea-mcp-failure/findings.md new file mode 100644 index 0000000..540d358 --- /dev/null +++ b/.planning/2026-08-24-gitea-mcp-failure/findings.md @@ -0,0 +1,13 @@ +# Gitea MCP 失败链路发现 + +- 当前宿主机 `command -v gitea-mcp` 无输出;server.js 已有命令存在性检查, + 缺失时抛出稳定错误码 `gitea_mcp_not_found`。 +- runner 在命令检查后创建/复用 Codex App 会话,再通过 + `thread/start.config.mcp_servers.gitea` 注入 stdio MCP;当前没有独立的 MCP + initialize 预检。 +- Codex App `thread/start` 请求超时为 60 秒;如果 MCP 子进程存在但启动后立即 + 退出,任务可能等待较久或只在 app-server 事件出现后才失败。 +- 队列异常终态会发送一次 `giteabot: 任务失败:...`;正常流程只发送起始的 + `giteabot: 已收到`,最终正文由 MCP/REST 兜底负责。 +- 当前工作区存在大量其他代理未提交改动,本轮只改动与 MCP 失败链路直接相关的 + 文件和独立测试/文档,禁止 reset 或覆盖无关变更。 diff --git a/.planning/2026-08-24-gitea-mcp-failure/progress.md b/.planning/2026-08-24-gitea-mcp-failure/progress.md new file mode 100644 index 0000000..f44c037 --- /dev/null +++ b/.planning/2026-08-24-gitea-mcp-failure/progress.md @@ -0,0 +1,9 @@ +# Gitea MCP 失败链路进度 + +## 记录 + +- 2026-08-24:建立本轮持久化计划;确认服务已由 PM2 `ccweb` 在线运行,未执行重启。 +- 2026-08-24:确认缺失命令已有快速失败,但命令存在且 stdio 握手失败仍缺少预检。 +- 2026-08-24:计划审查指出必须单独验收失败回执只发送一次、三类故障分流、线程级注入保持不变和不自动安装;已补入计划。 +- 2026-08-24:新增 `lib/gitea-mcp-probe.js`,在启动 Codex App 前发送 MCP `initialize`,对启动失败、握手错误和超时返回稳定错误码。 +- 2026-08-24:新增 `fixtures/gitea-workflow/mock-gitea-mcp.js` 与 `scripts/gitea-mcp-probe-unit.js`,预检成功、错误响应、进程退出、超时和命令不存在场景通过。 diff --git a/.planning/2026-08-24-gitea-mcp-failure/task_plan.md b/.planning/2026-08-24-gitea-mcp-failure/task_plan.md new file mode 100644 index 0000000..2034ec2 --- /dev/null +++ b/.planning/2026-08-24-gitea-mcp-failure/task_plan.md @@ -0,0 +1,27 @@ +# Gitea MCP 失败链路收尾计划 + +## 目标 + +让 Gitea Workflow 在官方 `gitea-mcp` 缺失、无法启动或 stdio 握手失败时, +在有限时间内结束任务并发送一次可见失败回执;正常 MCP 链路保持线程级注入, +不引入 Docker 依赖、不修改用户未授权的部署配置。 + +## 步骤 + +- [in_progress] 核对当前 Gitea runner、Codex App 启动和回执结算链路 +- [pending] 增加 gitea-mcp stdio 预检与有限超时错误分类 +- [pending] 增加针对缺失、启动失败、握手失败的独立回归断言,并验证每类只发一次 giteabot 失败回执 +- [pending] 同步中文文档和运行配置说明,明确线程级注入与不自动安装约束 +- [pending] 运行静态检查、单测、Webhook 回归并核对运行状态 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 全局技能路径不存在 | 1 | 改用项目内 `.codex/skills/planning-with-files` 与用户目录 `todo-list-csv` | + +## 决策 + +- 不自动安装外部二进制;安装属于外部系统变更,需用户明确确认。 +- 预检只验证命令可执行和 MCP initialize 响应,不记录 Token 或完整 stderr。 +- Gitea Workflow 任务失败回执仍统一使用 `giteabot:` 前缀。 diff --git a/.planning/2026-08-24-gitea-webhook-docs/findings.md b/.planning/2026-08-24-gitea-webhook-docs/findings.md new file mode 100644 index 0000000..f80add4 --- /dev/null +++ b/.planning/2026-08-24-gitea-webhook-docs/findings.md @@ -0,0 +1,21 @@ +# 发现记录 + +## 现状 + +- `docs/gitea-workflow/PRD.md` 与 `ARCHITECTURE.md` 已存在,已约定 MVP 为单 + Gitea 实例、`ccweb-bot`、同仓库串行、全局并发 2、线程级 `gitea-mcp`。 +- 当前仓库没有 Gitea 业务实现入口;本轮脚本必须测试协议/状态机纯函数,不应 + 假设或改写 `server.js`。 +- `package.json` 只有 `npm run regression`;新增脚本应提供独立 `node` 入口, + 便于实现接入阶段先运行夹具回归。 + +## 交付设计 + +- `DEPLOYMENT.md`:配置分层、Webhook 反向代理、凭据、目录权限、启动顺序、 + 健康检查、暂停/恢复、备份恢复、故障处置、日志脱敏和容量基线。 +- `TESTING.md`:测试金字塔、fixtures 协议、场景矩阵、故障注入、恢复/重试 + 断言、MCP `thread/start.config.mcp_servers.gitea` 契约与安全测试。 +- `scripts/gitea-webhook-regression.js`:零依赖 Node 脚本,加载 fixtures, + 对原始 body 做 HMAC-SHA256 常量时间校验,验证事件过滤、delivery 去重、 + 队列恢复/并发互斥、回执补发/REST 兜底、线程级 MCP 配置、路径边界以及敏感 + 信息不泄漏。 diff --git a/.planning/2026-08-24-gitea-webhook-docs/progress.md b/.planning/2026-08-24-gitea-webhook-docs/progress.md new file mode 100644 index 0000000..2cd3a73 --- /dev/null +++ b/.planning/2026-08-24-gitea-webhook-docs/progress.md @@ -0,0 +1,10 @@ +# 进度日志 + +- 2026-08-24:启用 `todo-list-csv`,建立六步计划和独立清单。 +- 2026-08-24:确认 PRD/ARCHITECTURE 已由其他工作流提供,本轮仅新增部署/测试 + 文档、独立脚本与 fixtures,避免修改业务主逻辑和现有目标文档。 +- 2026-08-24:计划审查第一次发现交付范围和测试入口未明确;已修正并复审通过。 +- 2026-08-24:新增 `DEPLOYMENT.md`、`TESTING.md`、7 个 JSON fixture 文件和独立 + `scripts/gitea-webhook-regression.js`;脚本覆盖 9 个离线场景。 +- 2026-08-24:`node --check`、`timeout 60s node scripts/gitea-webhook-regression.js` + 和 `git diff --check` 全部通过。 diff --git a/.planning/2026-08-24-gitea-webhook-docs/task_plan.md b/.planning/2026-08-24-gitea-webhook-docs/task_plan.md new file mode 100644 index 0000000..ba449e5 --- /dev/null +++ b/.planning/2026-08-24-gitea-webhook-docs/task_plan.md @@ -0,0 +1,30 @@ +# Gitea Webhook Agent 文档与回归计划 + +## 目标 + +补齐 Gitea Workflow 的部署运维和测试设计文档,并新增不依赖业务实现的 +Webhook 协议回归脚本与 fixtures,覆盖验签、事件过滤、队列恢复、回执重试 +兜底、MCP 线程配置和安全边界;不修改 `server.js` 主逻辑,也不覆盖其他代理 +正在进行的 Gitea 文档或实现改动。 + +## 步骤 + +1. 盘点并复核 `docs/gitea-workflow/PRD.md`、`ARCHITECTURE.md`,确认需求覆盖和 + 与其他代理的修改边界 +2. 补齐/校验 PRD 与架构交付基线,并设计部署运维文档固定安全与恢复契约 +3. 设计测试文档并建立需求到回归场景覆盖矩阵 +4. 新增独立 webhook 回归脚本与 JSON fixtures,固定直接验收入口 +5. 运行定向回归、脚本语法检查和差异范围审查 +6. 完成文档一致性复核并清理临时清单 + +## 约束 + +- 现有 `PRD.md`、`ARCHITECTURE.md` 属于其他代理的工作区:先做只读一致性复核; + 只有发现本需求明确缺口且确认不覆盖其改动时才做最小追加,否则在新增文档中 + 记录已覆盖项和缺口。 +- 本轮新增/修改目标为 `docs/gitea-workflow/DEPLOYMENT.md`、`TESTING.md`、 + `scripts/gitea-webhook-regression.js` 及 `fixtures/gitea-workflow/*.json`, + 并在新增文档中明确引用 PRD/架构路径。 +- 独立验收入口固定为:`timeout 60s node scripts/gitea-webhook-regression.js`; + 脚本输出每个场景的 PASS/FAIL 与最终汇总,不依赖服务启动或网络。 +- 不修改 `server.js` 主逻辑、`lib/`、`public/`、依赖和其他代理的目标文档。 diff --git a/.planning/2026-08-24-gitea-workflow-management/task_plan.md b/.planning/2026-08-24-gitea-workflow-management/task_plan.md new file mode 100644 index 0000000..c6bac0a --- /dev/null +++ b/.planning/2026-08-24-gitea-workflow-management/task_plan.md @@ -0,0 +1,24 @@ +# Gitea Workflow 管理 API 与页面实施计划 + +## 目标 + +在不覆盖 Gitea Workflow 核心领域模块的前提下,为 cc-web 增加可挂接的管理 API、 +前端管理页面和最小回归测试。页面展示仓库、队列、turn、日志与脏目录原因,并提供 +全局暂停、仓库启停、取消排队、中止 turn 及操作者/原因审计。 + +## 步骤 + +1. 读取现有前端路由、API 契约并确定挂接边界 +2. 实现独立 Gitea Workflow 管理 API 适配器与服务注入契约 +3. 接入 server.js 路由、鉴权与前端资源版本计算 +4. 实现 Workflow 管理页面脚本、样式并挂到现有入口 +5. 补充最小 API 与前端契约测试 +6. 运行语法检查、单测与回归并修复问题 +7. 汇总变更、清理临时清单并回报交付 + +## 边界 + +- 新增管理适配器与前端资源;核心队列/工作区/会话实现由注入的 service 提供。 +- server.js 只做路由挂接和鉴权,不复制领域状态机。 +- 兼容 service 不可用时的只读空状态,控制操作返回明确的 503。 +- 不安装依赖,不修改与本任务无关的现有未提交文件。 diff --git a/.planning/gitea-workflow-core/findings.md b/.planning/gitea-workflow-core/findings.md new file mode 100644 index 0000000..de93e5b --- /dev/null +++ b/.planning/gitea-workflow-core/findings.md @@ -0,0 +1,10 @@ +# Gitea Workflow 核心模块发现 + +- PRD 位于 `docs/gitea-workflow/PRD.md`,当前只确认了需求基线,`ARCHITECTURE.md` 尚不存在。 +- 现有 Node 项目使用 CommonJS,核心业务模块集中在 `lib/`,测试多为 `scripts/*-unit.js` 的直接执行脚本。 +- `server.js` 体量很大,本轮应新增独立文件并避免直接改写入口。 +- 当前工作区已有用户未提交改动和多个 Gitea 规划文件,需避免覆盖。 +- `home-cc-web` 的 codebase-memory 索引状态为 `ready`(6760 节点、15125 边);整体由 `server`、`app`、`task-board`、`codex` 等包组成,本轮新增模块可独立放入 `lib/`。 +- 项目无测试框架脚本,单元测试惯例是 `node scripts/*-unit.js` 直接执行并使用 Node 内置 `assert`。 +- 当前并行实现已形成两层契约:`gitea-workflow-domain/store/queue/service` 是可复用领域与 Webhook 编排层;`gitea-workflow-core.js` 保留轻量兼容服务并补充 turn/配置脱敏/审计 API。server.js 当前通过 `gitea-workflow-service.js` 注入 `setRunner` 与 `enqueueNormalizedTask`,不需要复制状态机。 +- 生产 runner 仍依赖 Gitea host/token、`gitea-mcp` 可执行文件、工作区 clone/fetch 和 Codex App 回执查询;这些属于集成配置/外部依赖,不在本轮单元测试中连接真实 Gitea。 diff --git a/.planning/gitea-workflow-core/progress.md b/.planning/gitea-workflow-core/progress.md new file mode 100644 index 0000000..84c408d --- /dev/null +++ b/.planning/gitea-workflow-core/progress.md @@ -0,0 +1,11 @@ +# Gitea Workflow 核心模块进度 + +## Log + +- 已读取项目 AGENTS、规划技能、PRD 与现有 lib/test 风格。 +- 已确认本轮使用独立 `lib/gitea-workflow-*.js` 文件和独立单元测试脚本。 +- 已新增 `gitea-workflow-domain.js`、`gitea-workflow-store.js`、`gitea-workflow-queue.js`、`gitea-workflow-service.js`;并补齐现有 `gitea-workflow-core.js` 的 turn、配置脱敏、规范化任务入队和 server runner 挂接契约。 +- 核心单测从 6 项扩展为 7 项,覆盖验签、mention/Bot 过滤、delivery 去重、持久化恢复、并发队列、MCP 配置和 `enqueueNormalizedTask`。 +- 已通过 `node --check`、核心单测、Webhook 回归、工作区单测、管理 API 单测和 `npm run regression`。 +- 已补充 `expectedVersion` 乐观并发校验,旧状态写入会返回 `version_conflict`,并将资源 `resourceKey` 对齐为 `::`。 +- 已清理本轮 server smoke 产生的临时 `config/gitea-*` 状态与空工作区锁目录,避免运行态文件混入交付。 diff --git a/.planning/gitea-workflow-core/task_plan.md b/.planning/gitea-workflow-core/task_plan.md new file mode 100644 index 0000000..457f9c7 --- /dev/null +++ b/.planning/gitea-workflow-core/task_plan.md @@ -0,0 +1,21 @@ +# Gitea Workflow 核心领域模块实施计划 + +## Goal + +在不重写 `server.js` 的前提下,为 Gitea Webhook 工作流新增可独立使用的核心领域模块,覆盖配置模型、仓库/会话/turn 状态、持久化、delivery 去重、仓库串行队列、重启恢复与审计事件,并提供测试或可调用接口。 + +## Status + +- [x] 读取现有代码与 PRD/架构约束 +- [x] 实现领域模型、状态机与配置规范化 +- [x] 实现持久化、去重、队列与重启恢复 +- [x] 实现审计记录与可调用服务接口 +- [x] 补充测试并完成静态/运行验证 +- [x] 汇总接口契约、修改文件与未决集成点 + +## Errors Encountered + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 全局 planning-with-files 路径不存在 | 1 | 改用项目内 `.codex/skills/planning-with-files` | +| `todo-list-csv` 技能文件未发现 | 1 | 手工创建同等格式的任务 CSV | diff --git a/.trellis/tasks/08-24-2026-08-24-gitea-workflow/check.jsonl b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-24-2026-08-24-gitea-workflow/implement.jsonl b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-24-2026-08-24-gitea-workflow/task.json b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/task.json new file mode 100644 index 0000000..e0844e1 --- /dev/null +++ b/.trellis/tasks/08-24-2026-08-24-gitea-workflow/task.json @@ -0,0 +1,26 @@ +{ + "id": "2026-08-24-gitea-workflow", + "name": "2026-08-24-gitea-workflow", + "title": "Gitea Webhook 驱动 Codex App 工作流", + "description": "", + "status": "planning", + "dev_type": null, + "scope": "server.js,lib,codexapp,public,docs", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/Gitea MCP 失败链路收尾 TO DO list.csv b/Gitea MCP 失败链路收尾 TO DO list.csv new file mode 100644 index 0000000..2c1866c --- /dev/null +++ b/Gitea MCP 失败链路收尾 TO DO list.csv @@ -0,0 +1,6 @@ +id,item,status,done_at,notes +1,核对当前 Gitea runner、Codex App 启动和回执结算链路,DONE,2026-08-24T16:09:10+08:00,已核对 server.js runner、Codex App thread/start 和回执结算路径 +2,增加 gitea-mcp stdio 预检与有限超时错误分类,DONE,2026-08-24T16:15:24+08:00,已接入 stdio initialize 预检、稳定错误码和测试 mock +3,增加针对缺失、启动失败、握手失败的独立回归断言,并验证每类只发一次 giteabot 失败回执,DONE,2026-08-24T16:16:08+08:00,独立预检单测覆盖四类失败、线程注入和失败回执守门;全部通过 +4,同步中文文档和运行配置说明,明确线程级注入与不自动安装约束,DONE,2026-08-24T16:16:08+08:00,已同步 ARCHITECTURE/CODEX-INTEGRATION/DEPLOYMENT/TESTING 与 .env.example +5,运行静态检查、单测、Webhook 回归并核对运行状态,IN_PROGRESS,, diff --git a/Gitea Workflow 管理页收缩整改 TO DO list.csv b/Gitea Workflow 管理页收缩整改 TO DO list.csv new file mode 100644 index 0000000..8dab5a7 --- /dev/null +++ b/Gitea Workflow 管理页收缩整改 TO DO list.csv @@ -0,0 +1,6 @@ +id,item,status,done_at,notes +1,核对后端配置提交兼容性与当前页面字段,DONE,2026-08-24T09:59:38+08:00,已完成控制区/配置区收缩,开始做验证 +2,收缩 gitea-workflow.js 配置与控制区,IN_PROGRESS,, +3,同步 gitea-workflow.css 以移除多余输入样式,TODO,, +4,运行语法与专项回归检查,TODO,, +5,真实浏览器验证 washi 和 carbon 双视口,TODO,, diff --git a/README.md b/README.md index f1b10ef..9719c35 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,17 @@ WantedBy=multi-user.target - 对公网开放时建议放到 HTTPS 反向代理后,并使用强密码 +# Gitea Workflow + +cc-web 已支持在 Gitea Issue/PR 普通评论中 `@ccweb-bot` 触发 Codex App 工作流。 +完整需求、部署、配置和验收说明见 [`docs/gitea-workflow/PRD.md`](docs/gitea-workflow/PRD.md)、 +[`docs/gitea-workflow/DEPLOYMENT.md`](docs/gitea-workflow/DEPLOYMENT.md)。 + +最小配置:设置 `CC_WEB_GITEA_HOST`、`CC_WEB_GITEA_BOT_TOKEN`、 +`CC_WEB_GITEA_WEBHOOK_SECRET` 和 `CC_WEB_GITEA_WORKSPACE_ROOT`,在 Gitea 创建指向 +`POST /api/gitea/webhook` 的 Webhook(启用 Secret),然后安装官方 `gitea-mcp`。 +管理页可查看任务/仓库/审计并执行暂停、停用、取消和中止。 + ## 其他 本项目源自 cc-web,并在实际使用中围绕 Codex App 与 ccweb MCP 做了重构和扩展。 diff --git a/bin/gitea-mcp b/bin/gitea-mcp new file mode 100755 index 0000000..650c2b8 Binary files /dev/null and b/bin/gitea-mcp differ diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index d6afe1b..6b1d1bd 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-08-20T15:45:04.108Z", + "updatedAt": "2026-08-23T19:06:55.618Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 5f85ae1..733303c 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/docs/gitea-workflow/ARCHITECTURE.md b/docs/gitea-workflow/ARCHITECTURE.md new file mode 100644 index 0000000..37ab945 --- /dev/null +++ b/docs/gitea-workflow/ARCHITECTURE.md @@ -0,0 +1,510 @@ +# 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、状态迁移、重启、 +并发、回帖确认和安全测试。任何无法满足的契约必须在对应审计/错误码中显式降级, +不得静默跳过。 + +实现已按本文边界落地;后续修改不得绕过验签、状态机、幂等键和回帖核验,也不得 +新增未审计的外部写操作。 diff --git a/docs/gitea-workflow/CODEX-INTEGRATION.md b/docs/gitea-workflow/CODEX-INTEGRATION.md new file mode 100644 index 0000000..f81c87b --- /dev/null +++ b/docs/gitea-workflow/CODEX-INTEGRATION.md @@ -0,0 +1,84 @@ +# Codex App / Gitea Workflow 挂接说明 + +本文件对应 `lib/gitea-workflow-codex.js` 的独立协议适配器,以及 `server.js` +中保持薄层的三个挂接点。 + +## 线程级 gitea-mcp + +Webhook runner 为每个任务创建工作区和 token 后,将如下对象随本轮消息传入: + +```js +{ + giteaWorkflow: { + taskId: task.taskId, + kind: 'normal', + mcp: { host: 'https://gitea.example', accessToken: token }, + }, +} +``` + +`codexAppThreadConfig()` 会将其合并为: + +```json +{ + "mcp_servers.gitea": { + "type": "stdio", + "command": "gitea-mcp", + "args": ["-t", "stdio", "-H", "https://gitea.example"], + "env": { + "GITEA_HOST": "https://gitea.example", + "GITEA_ACCESS_TOKEN": "<线程级凭据>" + } + } +} +``` + +token 不写入 app-server 进程环境,也不通过 dynamicTools 注入。使用 +`collaborationMode` 时,模型与推理参数仍只放在 `settings`。 + +`gitea-mcp` 二进制随 cc-web 交付,源码部署默认查找 +`/bin/gitea-mcp`,single-exe 部署默认查找 +`<发布目录>/bin/gitea-mcp`。只有需要替换官方版本时才配置 +`CC_WEB_GITEA_MCP_COMMAND` 指向本机绝对路径;不要求每个服务各自安装一份。 +命令不可执行时,任务在启动 Codex App 前以 `gitea_mcp_not_found` 快速失败,不会 +让对话长期停留在 `running`。 + +在启动 Codex App 前,cc-web 还会用同一份线程级 command/args/env 做一次最小 +stdio `initialize` 预检。预检只验证进程能启动并返回 JSON-RPC initialize 响应, +不调用 Gitea 工具;进程启动失败、返回 MCP error 或在 +`CC_WEB_GITEA_MCP_STARTUP_TIMEOUT_MS`(默认 8 秒)内无响应时,分别记录 +`gitea_mcp_start_failed`、`gitea_mcp_handshake_failed` 或 +`gitea_mcp_handshake_timeout`,任务进入 `failed`,并由统一队列监听发送一条 +`giteabot: 任务失败:...` 回执。cc-web 不会自动下载或安装 gitea-mcp。 + +## turn 生命周期 + +- `startCodexAppTurn()` 在 `turn/start` 返回后调用适配器的 + `handleTurnStarted({ taskId, threadId, turnId, kind })`。 +- `handleCodexAppTurnComplete()` 保留原有消息落盘和生命周期广播,并让既有 + Gitea waiter 调用 `settleGiteaWorkflowTurn()` 做回执查询、一次隐藏补触发和 + REST 兜底。 +- 运行时通知可用 `extractTurnId()` / `extractThreadId()` 兼容 + `params.turnId`、`params.turn.id`、`params.item.turnId` 等形状。 + +## 回执与 waiting_user + +`buildWorkflowMarker()` 生成版本化 HTML 注释: + +```html + +``` + +只有同时匹配资源、taskId、turnId(含补发轮次允许的历史标识集合)和 Bot 身份的评论才算 +`confirmed`。查询异常为 `unknown`,不得据此再次执行修改、补发或 REST 覆盖;只有确认 +评论缺失才允许同一 thread 发起一次 `reply_retry`,其提示词只允许补发上一轮最终文本, +仍未确认才进入 REST 兜底。 + +Agent 需要澄清或报告阻塞时使用同一标识结构但将 `kind` 设为 `waiting_user`。cc-web +确认该标识后直接把任务置为 `waiting_user`,不触发回帖补发;用户再次 `@ccweb-bot` +时创建新 turn,并复用原 Issue/PR 的 session/thread。 + +`queueWaitingUserComment()` 为用户的后续评论创建新 task/turn,但复用父任务的 +`sessionKey` 与 `threadId`;Bot 自评论按稳定 user id 优先、login 兜底过滤。 +带有完整 `ccweb-gitea` 隐藏标记的评论即使作者身份字段缺失,也按自身回执忽略, +避免回执 Webhook 形成循环。 diff --git a/docs/gitea-workflow/DEPLOYMENT.md b/docs/gitea-workflow/DEPLOYMENT.md new file mode 100644 index 0000000..8f4124c --- /dev/null +++ b/docs/gitea-workflow/DEPLOYMENT.md @@ -0,0 +1,147 @@ +# Gitea Webhook Agent 部署与运维设计 + +> 本文是 [PRD.md](./PRD.md) 与 [ARCHITECTURE.md](./ARCHITECTURE.md) 的运行手册。 +> 它定义部署、凭据、恢复和故障处置边界;Gitea 仍是唯一主交互入口,cc-web 页面 +> 只承担配置、镜像和运维控制。 + +## 1. 运行基线 + +MVP 采用单 Gitea 实例(`instanceId=default`)和专用 `ccweb-bot`。同仓库永远 +串行,跨仓库允许并行,不设置全局并发上限。生产环境至少需要: + +| 项目 | 基线 | +|---|---| +| Node/Bun | 使用项目锁定的运行时和已验收的单文件发布包;不要混用系统 Node | +| 进程 | 一个 cc-web worker;外部 Codex App Server 按会话复用 | +| 持久目录 | `WORKFLOW_DATA_ROOT`(任务/审计/租约)与 `WORKSPACE_ROOT`(Git 工作区)分离 | +| 网络 | Gitea Webhook 入站、Gitea REST/MCP 出站、Codex App Server 本地 stdio/JSON-RPC | +| 时间 | 主机启用 NTP;持久化时间全部使用 UTC ISO-8601 | +| 访问 | 反向代理终止 TLS;Webhook 路径只允许 POST 和受限请求体大小 | + +建议为 worker、工作区和日志使用独立系统用户。`WORKFLOW_DATA_ROOT` 仅 worker +可读写,`WORKSPACE_ROOT` 不允许 Web 静态服务暴露,备份目录使用不同权限主体。 + +## 2. 配置与凭据 + +配置按“非敏感文件 → Secret 管理 → 线程级注入”分层。示例(值均为占位符): + +```ini +CC_WEB_GITEA_HOST=https://gitea.example +CC_WEB_GITEA_INSTANCE_ID=default +CC_WEB_GITEA_BOT_LOGIN=ccweb-bot +GITEA_WEBHOOK_SECRET_FILE=/etc/ccweb/secrets/gitea-webhook +GITEA_BOT_TOKEN_FILE=/etc/ccweb/secrets/gitea-bot-token +CC_WEB_GITEA_WORKFLOW_STATE=/var/lib/ccweb/gitea-workflow/state.json +CC_WEB_GITEA_WORKSPACE_ROOT=/var/lib/ccweb/workspaces +``` + +cc-web 管理页对应接口为: + +- `GET /api/gitea-workflow/config`:返回地址、Bot 用户名、工作区和凭据是否已配置;不会返回 Secret/Token。 +- `PUT /api/gitea-workflow/config`:保存上述配置,敏感字段写入受保护的运行配置;保存后立即更新当前进程的 Webhook、Git 和 gitea-mcp 线程配置,不要求重启 cc-web。 +- `GET /api/gitea-workflow/overview`、`/tasks`、`/audit`:状态镜像。 +- `POST /api/gitea-workflow/control/pause|resume`、仓库 `enable|disable`、任务 `cancel|abort`:需要 cc-web 登录 Bearer Token;服务端自动记录操作来源和请求幂等 ID,页面不要求用户填写审计字段。 + +- 内部部署可以不配置 Webhook Secret;配置后入口启用 HMAC-SHA256 验签。Bot Token + 只在 gitea-mcp/REST 出站边界使用,不能把 Bot Token 当作 Webhook Secret。 +- Secret 文件权限为 `0600`、属主为 worker;日志、审计、评论正文和 Git remote + URL 禁止写入 Secret。审计只记录 `secretRef/tokenRef` 和不可逆指纹。 +- `thread/start.config.mcp_servers.gitea.env` 使用线程级 secret 引用或运行时 + 注入值;不能把某个来源会话 ID、Bot Token 或 Gitea Secret 放在长驻 + app-server 的进程级全局环境中。 +- `gitea-mcp` 随 cc-web 源码/发布目录放在 `bin/gitea-mcp`,不需要为每个服务单独 + 安装。只有替换版本时才使用 `CC_WEB_GITEA_MCP_COMMAND` 指向本机绝对路径;可选的 + `CC_WEB_GITEA_MCP_STARTUP_TIMEOUT_MS` 控制 initialize 预检超时(默认 8000ms, + 上限 30000ms)。cc-web 发布流程不从网络自动下载或安装外部二进制。 +- 轮换 Secret 时先配置新值并验证签名,再撤销旧值;未配置 Secret 的内部入口应 + 由反向代理/网络白名单限制来源。Bot Token 更新后立即作用于后续 MCP 线程。 + +## 3. Webhook 与反向代理 + +Gitea 只向反向代理暴露的 HTTPS 路径发送 Webhook。代理应: + +1. 严格保留原始请求 body 和 `X-Gitea-Signature`、`X-Gitea-Delivery`、 + `X-Gitea-Event` 头,不做 JSON 重排后再交给验签层。 +2. 限制方法为 POST、请求体大小(建议 1 MiB)和连接超时;超限返回 413。 +3. 只将来自 Gitea 网段的请求转发到 worker;不要在代理层伪造签名或 delivery。 +4. 配置了 Secret 时,worker 验签失败返回 401;解析失败返回 400,首次入队返回 + 202,重复 delivery/忽略事件返回 200。未配置 Secret 的内部入口仍需由代理 + 限制来源;持久化不可用时返回 503。 +5. 代理访问日志脱敏 `Authorization`、签名值和正文;保留 request ID 以便与 + `deliveryKey` 关联。 + +验签必须针对 raw body 计算 HMAC-SHA256,并使用常量时间比较。解析后的 mention +和仓库字段不能替代 raw body 验签;缺少 delivery header 时只能使用可靠 payload +ID,否则拒绝入队。 + +## 4. 启动、健康检查与发布 + +启动顺序固定为: + +1. 检查配置文件、Secret 文件权限、数据目录可写性和工作区根目录是否在允许 + 根下;禁止自动 `reset --hard` 或 `clean`。 +2. 打开持久 Store,执行租约过期扫描和恢复扫描;`queued` 重新入队, + `running/preparing/aborting` 按架构规则转为带 `interrupted_by_restart` 的 + `retry_wait`,终态不重跑。 +3. 启动 Scheduler 和受控的 Codex App Server;完成 `initialize` 后发送 + `initialized`,再 best-effort 探测 goals 与 collaboration mode。 +4. 仅在健康检查通过后把 Webhook 代理切入当前 worker。 + +健康检查至少包含:Store 读写探针、租约扫描完成、Gitea `/api/v1/version` 或 + 等价只读探针、Codex App Server initialize smoke,以及线程级 gitea-mcp + stdio `initialize` 预检。健康检查不得创建评论、修改仓库或打印凭据。 + +发布采用“先旁路验证、再切流”:在 staging 用回归 fixtures 和真实 Gitea 测试 +仓库验证 HMAC、入队、MCP 配置与回帖确认;生产切换前确认旧 worker 已停止领取 +新任务且租约已释放。回滚只切换到上一份已验收发布包和数据快照,不删除当前 +工作区或审计。 + +## 5. 日常运维控制 + +| 操作 | 影响 | 约束 | +|---|---|---| +| 全局 pause | 停止领取新任务,已运行任务继续 | 页面按钮直接执行,服务端记录操作 | +| resume | 恢复调度 | 先检查工作区和 Gitea 连通性 | +| repo disable | 拒绝该仓库新触发 | 不强杀运行中任务,保留历史 | +| cancel queued | 取消未启动任务 | 幂等;不得取消已领取任务 | +| abort running | 向 Codex App 请求取消 | 等待确认或超时后释放租约并审计 | +| replay delivery | 仅用于核对/恢复 | 先查 `deliveryKey`,禁止绕过去重直接执行 | + +所有控制操作必须通过受保护的运维 API;服务端生成操作记录和幂等 ID。API 不接受 +任意 Git 命令、模型切换或 MCP 工具参数。 + +## 6. 监控与告警 + +结构化日志只输出关联 ID:`deliveryKey、taskId、repoKey、sessionKey、threadId、 +turnId、leaseId、errorCode`。建议监控:Webhook 401/400/503 比例、队列年龄、锁等待、 +运行任务数量、`retry_wait` 数量、`blocked_workspace` 数量、MCP 握手失败、回帖确认 +缺失和 REST 兜底次数。 + +以下情况应告警并暂停自动扩容:连续 HMAC 失败、同一 delivery payload hash +变化、租约频繁过期、回帖兜底连续失败、工作区出现未知 owner 或路径越界、 +Bot Token/Secret 可能泄露。告警正文不得包含 Secret、完整评论正文或源码。 + +## 7. 备份、恢复与数据保留 + +- 备份 Store 的任务、会话、turn、评论索引、控制面和 append-only 审计; + `webhook_deliveries` 至少保留覆盖 Gitea 重试窗口的记录。 +- 工作区是可重建缓存,备份前先记录 `repoKey、HEAD、dirty 状态`;不要把 + 未提交用户修改静默覆盖或当成可恢复快照。 +- 恢复顺序:停止领取 → 恢复 Store → 校验版本/唯一键 → 执行租约恢复扫描 → + 只读检查工作区 → 启动 Scheduler → 逐步 resume。恢复后优先核对 + `deliveryKey → taskId → turnId → commentId` 链路,避免重复回帖。 +- 清理策略必须由管理员显式启用并保留审计;不得因磁盘告警直接删除运行中 + 任务、未确认评论或工作区。 + +## 8. 故障处置速查 + +| 症状 | 先查 | 安全动作 | +|---|---|---| +| Webhook 全部 401 | Secret 引用、raw body、代理头 | 暂停切流,验证单个 fixture,不打印签名 | +| 任务堆积 | pause、锁、并发槽位、租约 | 不手工改状态;修复后让恢复扫描接管 | +| 工作区 blocked | `git status`、owner、锁文件 | 保留现场,管理员审查后再解除;禁止 reset/clean | +| App/MCP 不可达 | initialize、stdio stderr、hostRef、`gitea-mcp` 可执行文件 | 命令缺失、进程启动失败、握手失败或超时均立即进入 `failed` 并发送一条 `giteabot:` 失败回执;修复后重新触发 | +| MCP 成功但无评论 | resource/task/turn 标记、Gitea 查询 | 只允许一次 reply_retry,随后 REST 兜底 | +| 重启后重复执行 | stateVersion、leaseId、终态记录 | 先 pause,修复 Store/租约,再恢复;禁止批量重放 | + +每次事故结束后导出关联审计、错误码、重试次数和最终评论 ID,形成可复盘记录。 diff --git a/docs/gitea-workflow/PRD.md b/docs/gitea-workflow/PRD.md new file mode 100644 index 0000000..873798d --- /dev/null +++ b/docs/gitea-workflow/PRD.md @@ -0,0 +1,255 @@ +# Gitea Webhook 驱动 ccweb 工作流 PRD + +> 文档状态:已确认需求基线(MVP) +> 适用范围:单个 Gitea 实例、一个全局 `ccweb-bot` 账号 +> 相关设计:[ARCHITECTURE.md](./ARCHITECTURE.md) + +## 1. 背景与目标 + +本功能把 Gitea Issue/PR 的普通评论作为 ccweb 的任务入口。用户在评论中 +提及 `@ccweb-bot` 后,cc-web 验证 Webhook、准备持久工作区、复用该 +Issue/PR 的独立 Codex App 会话,并通过官方 `gitea-mcp` 完成代码研究、修改 +和回帖。Gitea 是唯一主交互入口;cc-web 页面只提供镜像、运维和审计能力。 + +目标: + +1. 让用户无需离开 Gitea 即可驱动一次或连续多轮工程任务。 +2. 保证同一仓库的工作区和任务严格串行,不同仓库可并行;MVP 不设置额外的 + 全局并发上限。 +3. 在进程重启、网络抖动、MCP 回帖失败时,任务状态、会话和回执仍可恢复、 + 重试且不重复执行或重复回帖。 +4. 对 Webhook、Bot Token、工作区写入和每次工具调用提供可追溯审计,并允许运 + 维人员暂停、取消排队和中止运行中的 turn。 + +## 2. 已确认决策 + +| 主题 | 决策 | +|---|---| +| Gitea 实例 | MVP 只支持一个配置的 Gitea 实例,`instanceId=default` | +| Bot | 一个全局 `ccweb-bot`,用于触发识别、起始回执和 REST 兜底 | +| 触发 | Issue 或 PR 的普通评论正文包含 `@ccweb-bot`;评论作者不能是 Bot 自身 | +| 自动接入 | 合法触发首次发现未登记仓库时自动登记并 clone;管理员可停用 | +| 工作区 | 可配置工作区根目录下按仓库持久 checkout,后续使用 fetch 更新 | +| 并发 | 同仓库串行;跨仓库并行;MVP 不设置全局运行上限 | +| Agent | 固定 Codex App(`codexapp`)+ `yolo`,不在 Gitea 交互中暴露模式切换 | +| MCP | 官方 `gitea-mcp`,stdio 传输;线程级注入 Gitea host/token | +| 会话 | 每个 Issue/PR 一个独立持久会话;后续同资源评论进入同一会话 | +| 交互 | Gitea 为主;cc-web 页面只展示状态、队列和审计,不替代 Gitea 对话 | +| 回复 | 先发一条“已收到”回执;正常最终答案由 gitea-mcp 发回原 Issue/PR;缺失时一次补触发,最后 REST 兜底 | +| 安全 | Webhook HMAC、delivery 去重、Bot 自评论过滤、Token/Secret 分离、审计 | +| 恢复 | `queued` 重启后继续;`waiting_user` 保留;`running` 视为中断并最多重试一次 | +| 运维 | 全局暂停、仓库停用、取消排队、中止 turn、查看审计 | + +## 3. 范围 + +### 3.1 In Scope + +- Gitea Webhook 接收、原始请求 HMAC 校验和 delivery 幂等。 +- Issue/PR 普通评论事件的规范化、提及解析和 Bot 自评论抑制。 +- 未登记仓库自动接入、持久 clone/fetch、脏工作区保护。 +- 任务队列、同仓库锁、跨仓库并行。 +- Issue/PR 独立会话与 Codex App turn 生命周期管理。 +- 线程级官方 `gitea-mcp` stdio 配置和 Gitea 主交互。 +- 起始“已收到”回执、MCP 最终回帖确认、一次补触发和 REST 兜底;不发送运行中、等待用户或成功状态评论。 +- 暂停、停用、取消排队、中止、重启恢复、审计和可观测字段。 +- 可供前端/运维使用的只读状态和控制接口契约(实现不在本 PRD 变更范围)。 + +### 3.2 Out of Scope + +- 多 Gitea 实例、跨 Forge 统一协议和 GitHub/GitLab 适配。 +- 非评论触发(Push、定时任务、Issue 创建、Actions 等)。 +- 让普通用户在 cc-web 中切换模型、推理强度或 yolo/plan 模式。 +- 自定义 dynamicTools 代替 MCP;手动填写 MCP 工具参数的 UI。 +- 自动清理用户未提交修改、`reset --hard`、强制覆盖其他会话的工作区。 +- 细粒度按用户/路径的写权限策略(Bot 全写权限是已确认前提);只记录风险并提供暂停/审计。 + +## 4. 角色与核心场景 + +| 角色 | 能力 | +|---|---| +| Gitea 用户 | 在 Issue/PR 评论 `@ccweb-bot`,查看状态、追问、确认结果 | +| `ccweb-bot` | 发送起始回执、作为 MCP/REST 身份回帖;Bot 自己的评论不会再次触发 | +| 运维管理员 | 全局暂停/恢复、仓库停用/启用、取消排队、中止 turn、查审计 | +| 系统 | 验签、去重、排队、clone/fetch、启动会话、确认回帖、重试和恢复 | + +典型链路: + +```text +Gitea 普通评论 @ccweb-bot + │ + ▼ +验签 → delivery 去重 → 解析 Issue/PR → 自动接入仓库 + │ │ + └────────────── 持久化任务并入队 ◄──┘ + │ + 同仓库锁串行,跨仓并行 ────┘ + ▼ + Codex App/yolo + gitea-mcp(stdio) + │ + 已收到回执 → MCP 最终回帖 → turnId/评论确认 + │ + 补触发一次 → REST 兜底(必要时) +``` + +## 5. 功能需求 + +### FR-01 触发与事件规范化 + +- 接受 Gitea 配置的 Issue/PR 评论 Webhook;事件适配器将不同版本的事件名称 + 归一为 `issue_comment` 或 `pull_request_comment`。 +- 仅当正文包含独立 mention `@ccweb-bot` 时触发,mention 后的文本作为任务 + 指令;没有有效指令时仍创建可追踪任务并要求用户补充。 +- 记录原始 `deliveryId`、事件类型、仓库、资源类型/编号、评论 ID、作者和 + 接收时间。无法解析的事件进入 `ignored`,不启动 Agent。 +- Bot 自己发出的状态/最终/兜底评论必须被识别并忽略,防止自触发环路。 + +### FR-02 Webhook 安全与幂等 + +- 在读取或持久化业务字段前,使用原始 body 和配置 Secret 计算 HMAC-SHA256, + 采用常量时间比较;验签失败返回 401,不入队。 +- `deliveryKey = instanceId + ":" + deliveryId` 唯一;已处理或处理中重复 + delivery 返回 200(或约定的幂等响应)但不创建新任务。 +- Secret、Bot Token、MCP 启动参数不得写入评论、普通日志或审计明文;审计只 + 记录凭据引用/哈希指纹。 + +### FR-03 仓库自动接入与工作区 + +- 通过合法 Webhook 首次发现仓库时原子创建 `RepositoryRecord`;未登记不因 + 任务排队失败而丢失事件。 +- 按 `workspaceRoot///` 建立持久目录。首次使用 + `clone`,后续任务在仓库锁内 `fetch`;凭据通过临时环境或 credential helper + 注入,不把 Token 写入 remote URL。 +- 检测到未提交修改、冲突或目录被外部占用时,不执行 reset/clean/覆盖;任务 + 进入 `blocked_workspace`,写入审计并等待管理员处理。 +- PR 任务必须记录 base/head ref 和源仓库信息;Issue 任务使用仓库默认分支, + 除非指令明确指定已允许的分支。 + +### FR-04 队列、锁与并发 + +- 调度器维护持久任务队列;同 `repoKey` 同时最多一个 `preparing/running` + 任务,跨仓库可并行。 +- 不设置额外全局运行信号量;`queued` 任务只受全局暂停和各自仓库锁影响,暂停时不再领取新任务。 +- 队列顺序默认 FIFO;同一资源的新评论可合并为下一轮输入,但不得跳过已持久 + 化的任务或破坏评论顺序。 +- 取消排队只允许作用于尚未启动的任务;中止操作必须向 Codex App 发送取消, + 并等待 `aborted` 或超时后释放锁。 + +### FR-05 会话与 Agent 运行时 + +- `sessionKey = instanceId:owner/repo:kind:number`,其中 `kind` 为 `issue` 或 + `pull_request`;同一资源永远复用同一个 `threadId`。 +- 每个新任务创建一个 `turnId`;持久化 `threadId/turnId`、启动时间、最后事件 + 序号和重试次数,支持断线后恢复监听。 +- 启动参数固定为 `codexapp` 与 `yolo`。`model`、`reasoning_effort`、 + `developer_instructions` 放入 `collaborationMode.settings`(若运行时启用 + collaborationMode),不得在顶层重复传递。 +- `thread/start.config.mcp_servers.gitea` 线程级注入官方 gitea-mcp stdio;每 + 个线程使用对应实例的 host/token,不使用进程全局来源会话变量替代。 +- Prompt 必须包含仓库/资源标识、评论上下文、工作区路径、预期回帖格式和安全 + 边界;评论正文视为不可信输入,不得覆盖系统约束。 + +### FR-06 Gitea 交互与回帖确认 + +- 合法任务只发送一条起始 `giteabot: 已收到` 回执;运行中、等待用户和成功状态 + 不再额外发送评论,避免污染 Issue/PR 讨论串。回执仍带不可见关联标记 + (`taskId`、`state`、可选 `turnId`)用于去重和审计。 +- 正常最终答案必须由 gitea-mcp 的 Issue/PR comment 工具发送;cc-web 记录 + MCP 返回的 `commentId` 或工具调用结果,并通过 Gitea 查询确认实际存在。 +- 确认必须同时匹配 `resourceKey`、`taskId`、`turnId`(或等价不可见标记)和 + Bot 作者,避免把旧评论当作本轮回执。 +- 仅在确认 Gitea 评论查询结果为“缺失”时,发起一次“只补发回执、不重复修改”的 + 隐藏 turn;补发仍缺失才以 Bot Token 经 Gitea REST 发布最终正文。查询结果为 + `unknown` 时不自动重试或覆盖,任务进入 `failed_reply`,由终态兜底回执告知用户, + 避免把已经成功落库的 MCP 回复重复发布。 + +### FR-07 重试、恢复与状态一致性 + +- 网络/进程级暂时错误使用持久 `retry_wait` 和指数退避;执行 turn 因重启被 + 中断时最多自动重试一次,禁止无界重试。 +- `queued` 重启后恢复调度;`waiting_user` 保留原会话等待新评论;`running` + 恢复为 `retry_wait` 并带 `interrupted_by_restart` 原因;终态不重新执行。 +- 所有状态变更、评论发送、MCP 工具调用和重试都追加审计事件,状态写入遵循 + 版本号/单调序列,防止旧事件覆盖新状态。 + +### FR-08 运维控制与审计 + +- 全局 `pause/resume`:暂停只阻止新任务领取,已运行任务继续或由管理员另行中止。 +- 仓库 `disable/enable`:停用后拒绝该仓库的新触发并保留历史;正在运行的任务 + 不强制杀死。 +- `cancel queued`、`abort running turn` 必须幂等,响应包含当前状态和操作者。 +- 审计事件至少包含 `eventId、taskId、sessionKey、actor、action、fromState、 + toState、deliveryId、turnId、commentId、errorCode、timestamp`;支持按仓库、 + 资源、任务和时间范围查询。 + +## 6. 状态机 + +### 6.1 任务状态 + +| 状态 | 含义 | 可转移 | +|---|---|---| +| `received` | 已验签并完成事件规范化 | `queued`、`ignored`、`duplicate`、`rejected` | +| `queued` | 等待仓库锁和全局槽位 | `preparing`、`cancelled` | +| `preparing` | 正在登记仓库、clone/fetch、构造线程 | `running`、`blocked_workspace`、`retry_wait`、`failed` | +| `running` | Codex App turn 执行中 | `waiting_user`、`verifying_reply`、`retry_wait`、`aborting`、`failed` | +| `waiting_user` | Agent 需要用户补充,保留会话 | `queued`(新评论)、`cancelled`、`failed` | +| `verifying_reply` | 已收到 turn 完成,等待确认最终评论 | `succeeded`、`retry_wait`、`failed_reply` | +| `retry_wait` | 到达退避时间,等待有限次重试 | `preparing`、`running`、`verifying_reply`、`failed` | +| `blocked_workspace` | 工作区脏/冲突/权限问题 | `queued`(管理员解除后)、`cancelled` | +| `aborting` | 已请求取消,等待 Codex App 确认 | `aborted`、`failed` | +| `succeeded` | 最终回帖已确认 | 终态 | +| `succeeded_with_rest_fallback` | 使用 REST 兜底回帖成功 | 终态 | +| `failed` / `failed_reply` | 执行或回帖不可恢复失败 | 终态 | +| `cancelled` / `aborted` | 排队取消或运行中止 | 终态 | +| `ignored` / `duplicate` / `rejected` | 未触发、重复或安全拒绝 | 终态 | + +### 6.2 状态不变量 + +1. 只有 `preparing/running/aborting` 持有仓库锁;同一 `repoKey` 同时最多一个 + 运行中任务。 +2. 终态任务不得创建新 turn、状态评论或重试;重复 Webhook 只能返回原任务。 +3. `verifying_reply` 必须引用本轮 `turnId`;`failed_reply` 不代表代码执行必然 + 失败,必须在审计中区分 `executionError` 与 `replyError`。 +4. `waiting_user` 的新评论创建新 `taskId`,但复用同一 `sessionKey/threadId`。 + +## 7. 非功能需求与风险 + +| 类别 | 要求 | +|---|---| +| 安全 | HMAC 常量时间校验、凭据隔离、Bot 自触发抑制、敏感字段脱敏 | +| 一致性 | delivery/task/session/turn/comment 均有唯一键;状态变更可重放且幂等 | +| 可恢复性 | 进程重启不丢队列、会话映射和回帖确认上下文 | +| 可观测性 | 结构化日志 + 审计事件 + task/session/turn 关联 ID | +| 性能 | Webhook 验签与入队不等待 Agent 完成;并发只受仓库锁约束 | +| 数据保留 | 任务/会话/审计至少保留到管理员显式清理策略生效;工作区持久保留 | +| 风险 | gitea-mcp 全写权限意味着 Bot 能修改代码和 Gitea 内容;通过 HMAC、暂停、 + 中止、审计和脏工作区保护降低风险,不声称实现细粒度授权 | + +## 8. 验收标准与需求覆盖矩阵 + +每个验收项都应能在集成测试、协议 mock 或只读审计中独立验证。 + +| ID | 验收标准 | 覆盖需求/设计 | +|---|---|---| +| AC-01 | 合法 Issue 普通评论含 `@ccweb-bot` 创建任务;无 mention、非评论或 Bot 自评论不创建任务 | FR-01 | +| AC-02 | HMAC 错误返回 401 且无业务写入;相同 delivery 重放不增加任务数 | FR-02 | +| AC-03 | 首次合法触发自动登记仓库并 clone;再次触发只 fetch;脏工作区不被 reset/覆盖 | FR-03 | +| AC-04 | 同仓库两个任务严格串行;两个仓库可并行;不额外施加全局并发上限 | FR-04 | +| AC-05 | 同一 Issue/PR 多条评论复用一个 `threadId`,每条任务有唯一 `turnId` | FR-05 | +| AC-06 | Codex App 运行时固定 `codexapp+yolo`;官方 gitea-mcp 以线程级 stdio 配置启动 | FR-05 | +| AC-07 | 合法任务仅有一条“已收到”起始回执;成功正文由 MCP/REST 兜底承担,失败或控制终态最多一条结果回执,Bot 评论不会回触发 | FR-06 | +| AC-08 | MCP 最终回帖能按 `taskId+turnId+resourceKey` 确认;缺失只补发一次,随后 REST 兜底 | FR-06 | +| AC-09 | 重启后 queued 继续、waiting_user 保留、running 最多重试一次;终态不重跑 | FR-07 | +| AC-10 | pause 阻止新领取;仓库 disable 拒绝新任务;cancel/abort 幂等且释放相应资源 | FR-08 | +| AC-11 | 审计可按 task/session/repo 查询,包含状态迁移、delivery、turn、comment 和错误字段 | FR-08 | +| AC-12 | PRD 与 ARCHITECTURE 的状态枚举、字段名、默认并发、回帖错误语义完全一致 | 文档一致性 | +| AC-13 | 生产挂接包含 `server.js` 路由、`lib/gitea-workflow-*` 核心模块、管理页与离线回归;不新增第三方依赖 | 交付约束 | + +## 9. 交付边界与后续演进 + +当前仓库已提供 MVP 实现:`POST /api/gitea/webhook`、持久任务/队列、工作区维护、 +Codex App 线程级 gitea-mcp 注入、回帖确认/兜底、管理 API/UI 和离线回归。实现与 +测试必须继续以本 PRD 的 FR/AC 编号作为审计索引。 + +后续可独立评估:多实例、细粒度授权、审批门、工作区隔离容器、任务预算、 +SSE/前端实时镜像和更多 Forge 适配;这些不改变 MVP 的 `sessionKey`、幂等和回帖 +确认约束。 diff --git a/docs/gitea-workflow/TESTING.md b/docs/gitea-workflow/TESTING.md new file mode 100644 index 0000000..f88dd97 --- /dev/null +++ b/docs/gitea-workflow/TESTING.md @@ -0,0 +1,155 @@ +# Gitea Webhook Agent 测试设计 + +> 本文与 [PRD.md](./PRD.md) 的 FR/AC 以及 +> [ARCHITECTURE.md](./ARCHITECTURE.md) 的字段、状态和错误矩阵对齐。独立协议 +> 回归入口为: +> +> ```bash +> timeout 60s node scripts/gitea-webhook-regression.js +> ``` +> +> 脚本只使用 Node 内置模块和 `fixtures/gitea-workflow/*.json`,不启动 +> `server.js`、不访问网络、不写业务 Store,适合实现落地前后的离线验收。 + +## 1. 测试金字塔 + +| 层级 | 目标 | 本轮入口 | +|---|---|---| +| 契约/纯函数 | HMAC、事件规范化、mention/Bot 过滤、幂等键 | `gitea-webhook-regression.js` | +| 状态模型 | queued/running/retry_wait/终态恢复和有限重试 | 同上 + 实现方状态机单测 | +| 协议集成 | HTTP header、Gitea REST、Codex JSON-RPC、MCP stdio | mock server/adapter 集成测试 | +| 端到端 | 真实 Gitea 测试仓库到最终评论 | staging 手工/CI smoke,不纳入离线脚本 | +| 运维验收 | 重启、备份恢复、pause/disable/abort、告警脱敏 | staging runbook 演练 | + +离线脚本是最小门禁;当前仓库已提供以下专项门禁: + +```bash +node scripts/gitea-webhook-regression.js +node scripts/gitea-webhook-workspace-unit.js +node scripts/gitea-workflow-codex-unit.js +node scripts/gitea-workflow-core-unit.js +node scripts/gitea-workflow-management-unit.js +``` + +仍需在 staging 用真实 Gitea 验证出站回帖确认;纯函数通过不能替代真实凭据和 +网络链路验收。 + +## 2. Fixtures 约定 + +所有 fixture 均为 JSON,放在 `fixtures/gitea-workflow/`,不含真实 Secret、Token、 +源码或生产 URL。字段约定: + +```json +{ + "name": "valid-issue-comment", + "headers": { + "x-gitea-event": "issue_comment", + "x-gitea-delivery": "delivery-001" + }, + "payload": { + "repository": {"owner": "acme", "name": "widget"}, + "issue": {"number": 42}, + "comment": {"id": 101, "body": "@ccweb-bot 请修复"}, + "sender": {"login": "alice", "is_bot": false} + }, + "expected": {"accepted": true, "resourceKind": "issue"} +} +``` + +脚本运行时用固定测试 Secret 计算签名;签名不落盘。需要拒绝签名时复制 +payload 后篡改 raw body 或签名头。fixture 变更必须同步场景 ID 和预期结果。 + +## 3. 需求覆盖矩阵 + +| 场景 ID | 验证内容 | Fixture/断言 | 对应 AC | +|---|---|---|---| +| WH-01 | 合法 Issue 评论 mention 触发,规范化 owner/repo/number/comment | `valid-issue-comment.json` | AC-01 | +| WH-02 | 合法 PR 评论进入 `pull_request` 资源,保留 base/head | `valid-pr-comment.json` | AC-01/03 | +| WH-03 | HMAC 缺失/错误不写业务任务,常量时间比较 | `valid-issue-comment.json` 篡改签名 | AC-02 | +| WH-04 | 相同 `instanceId:deliveryId` 重放返回 duplicate,不增 task | valid fixture 两次提交 | AC-02 | +| WH-05 | 无 mention、非评论事件、Bot 自评论、停用仓库均 ignored/rejected | `ignored-events.json`、`bot-self-comment.json` | AC-01/10 | +| Q-01 | 重启后 queued 保留;running 变 `retry_wait(interrupted_by_restart)` | `queue-recovery.json` | AC-09 | +| Q-02 | 已有重试次数达到上限后进入 failed;waiting_user/终态不重跑 | 同上 | AC-09 | +| Q-03 | 同仓库运行锁互斥;跨仓库可以并行,不设置额外全局活动上限 | 脚本内最小状态快照 | AC-04 | +| R-00 | 合法触发仅产生一条 `giteabot: 已收到` 起始回执;成功路径不产生运行中/等待/已完成状态评论 | server.js 回执策略断言 | AC-07 | +| R-01 | MCP 回帖成功但查询缺失只创建一个 reply_retry | `reply-failure.json` | AC-08 | +| R-02 | 补发仍失败才 REST 兜底,结果为 succeeded_with_rest_fallback | 同上 | AC-08 | +| MCP-01 | `thread/start.config.mcp_servers.gitea` 为线程级 stdio 配置 | `mcp-thread-config.json` | AC-06 | +| MCP-02 | collaborationMode 字段放 settings,顶层无 model/effort 重复字段 | 同上 | AC-06 | +| MCP-03 | gitea-mcp 命令缺失、进程启动即退、initialize 返回 error、握手超时分别映射稳定错误码 | `gitea-mcp-probe-unit.js` + mock stdio fixture | AC-06/07 | +| MCP-04 | 预检失败后任务只产生一条 `giteabot:` 失败终态回执;线程级注入仍保留且不自动安装二进制 | `gitea-mcp-probe-unit.js` 源码守门 | AC-06/07/11 | +| SEC-01 | Secret/token 不进入日志、评论、clone URL 或 fixture | 全部 fixture + 输出扫描 | AC-02/11 | +| SEC-02 | 路径穿越、任意命令、越权控制参数被拒绝 | 实现方安全单测 | AC-03/10/11 | + +## 4. 状态、恢复与并发测试 + +实现方 Store 测试必须验证以下不变量: + +1. `deliveryKey`、`sessionKey`、`taskId+turnId`、`commentKey` 唯一;重复请求 + 返回原记录且不创建新 turn/comment。 +2. 同一 `repoKey` 同时最多一个 `preparing/running/aborting`;不同 `repoKey` + 可以并行;pause 只阻止领取,不强制终止运行中任务。 +3. 重启扫描用 `stateVersion`/`leaseId` CAS,旧 worker 不能覆盖新租约; + `running` 最多自动恢复一次,超过上限进入 `failed`。 +4. `waiting_user` 保留 thread,新评论创建新 task 但复用 `sessionKey/threadId`; + 所有终态不再自动执行。 +5. `blocked_workspace` 不执行 reset/clean;管理员解除后才可重新入队。 + +故障注入至少覆盖进程在“intent 已落库、外部调用未完成”和“外部返回成功、 +结果未确认”两个窗口崩溃,恢复后必须通过幂等键继续,而非盲目重复修改。 + +## 5. 回帖重试测试 + +按以下顺序模拟: + +1. MCP comment 返回 accepted,但 Gitea 查询无匹配标记:状态进入 + `verifying_reply`,只允许一个 `reply_retry` turn。 +2. `reply_retry` 仍无评论:调用一次 REST 兜底,body 复用 `taskId/turnId/resourceKey` + 标记;查询成功为 `succeeded_with_rest_fallback`。 +3. REST 返回 2xx 但查询仍为空:状态必须是 `failed_reply`,不能虚报成功,也不得 + 无限重试。 +4. 原 MCP 评论已确认后重复 webhook/重启:不得再发送起始、状态、最终或兜底评论。 + +测试日志只输出 `taskId/turnId/commentId/errorCode`,不得输出正文中的 Secret 或 +完整 Authorization。 + +## 6. MCP/JSON-RPC 协议测试 + +mock app-server 必须断言初始化顺序为:`initialize` → `initialized` → best-effort +`experimentalFeature/enablement/set`、`collaborationMode/list` → `thread/start`。 +能力探测失败只记录降级,不阻断启动。 + +另需运行 `node scripts/gitea-mcp-probe-unit.js`,它会以独立 mock stdio 进程验证 +gitea-mcp 的 initialize 预检,不需要真实 Gitea、Token 或 Docker。 + +`thread/start` 断言: + +- `config.mcp_servers.gitea` 每个线程单独携带 host/token 引用和 stdio command; +- `CC_WEB_SOURCE_SESSION_ID` 等来源上下文不能只存在进程环境; +- 使用 collaboration mode 时,`model`、`reasoning_effort`、 + `developer_instructions` 只在 `collaborationMode.settings`,顶层不存在重复 + `model`/`effort`;拒绝 collaborationMode 时可降级普通 turn。 + +## 7. 安全与运维验收 + +- 验签使用 raw body + HMAC-SHA256 + 常量时间比较;缺失/错误签名 401 且无业务写入。 +- 事件正文、Issue 描述、代码和 PR 内容均视为不可信 prompt;不能改变系统约束、 + 读取 Secret、关闭审计或注入任意命令。 +- Bot 自评论、停用仓库、非评论事件和无 mention 事件不启动 Agent;每个决定有 + 审计 action/errorCode。 +- clone/fetch 使用不含 Token 的 remote URL;工作区路径必须在允许根目录内, + 路径穿越和脏目录覆盖均拒绝。 +- 控制 API 需要管理员身份、reason 和 idempotency key;取消/中止幂等。 +- 日志和审计脱敏检查覆盖 Secret、Bot Token、Authorization、MCP args/env。 + +## 8. 执行清单 + +```bash +node --check scripts/gitea-webhook-regression.js +timeout 60s node scripts/gitea-webhook-regression.js +git diff --check +``` + +CI 失败时保留 stdout/stderr、fixture 名称和场景 ID;不得重试隐藏不稳定失败。 +真实 Gitea staging 验收完成后,应将 delivery/task/thread/turn/comment 关联链和 +最终状态写入审计报告,作为发布批准依据。 diff --git a/fixtures/gitea-workflow/bot-self-comment.json b/fixtures/gitea-workflow/bot-self-comment.json new file mode 100644 index 0000000..06bc98b --- /dev/null +++ b/fixtures/gitea-workflow/bot-self-comment.json @@ -0,0 +1,11 @@ +{ + "name": "bot-self-comment", + "headers": {"x-gitea-event": "issue_comment", "x-gitea-delivery": "delivery-bot-001"}, + "payload": { + "repository": {"owner": "acme", "name": "widget"}, + "issue": {"number": 42, "pull_request": false}, + "comment": {"id": 303, "body": "@ccweb-bot 状态更新", "user": {"login": "ccweb-bot", "type": "Bot"}}, + "sender": {"login": "ccweb-bot", "is_bot": true} + }, + "expected": {"accepted": false, "reason": "bot_self_comment"} +} diff --git a/fixtures/gitea-workflow/ignored-events.json b/fixtures/gitea-workflow/ignored-events.json new file mode 100644 index 0000000..0a8cb00 --- /dev/null +++ b/fixtures/gitea-workflow/ignored-events.json @@ -0,0 +1,19 @@ +[ + { + "name": "push-event", + "headers": {"x-gitea-event": "push", "x-gitea-delivery": "delivery-push-001"}, + "payload": {"repository": {"owner": "acme", "name": "widget"}, "sender": {"login": "alice"}}, + "expected": {"accepted": false, "reason": "unsupported_event"} + }, + { + "name": "comment-without-mention", + "headers": {"x-gitea-event": "issue_comment", "x-gitea-delivery": "delivery-no-mention-001"}, + "payload": { + "repository": {"owner": "acme", "name": "widget"}, + "issue": {"number": 42, "pull_request": false}, + "comment": {"id": 404, "body": "请看看这个问题", "user": {"login": "alice"}}, + "sender": {"login": "alice", "is_bot": false} + }, + "expected": {"accepted": false, "reason": "missing_mention"} + } +] diff --git a/fixtures/gitea-workflow/mcp-thread-config.json b/fixtures/gitea-workflow/mcp-thread-config.json new file mode 100644 index 0000000..a99a1a9 --- /dev/null +++ b/fixtures/gitea-workflow/mcp-thread-config.json @@ -0,0 +1,16 @@ +{ + "cwd": "/var/lib/ccweb/workspaces/default/acme/widget", + "mcpServer": { + "name": "gitea", + "command": "gitea-mcp", + "args": ["-t", "stdio", "-H", "https://gitea.example"], + "env": {"GITEA_ACCESS_TOKEN": "secret-ref:gitea-bot-token"} + }, + "collaborationMode": { + "settings": { + "model": "configured-model", + "reasoning_effort": "medium", + "developer_instructions": "只在授权工作区内操作并回帖" + } + } +} diff --git a/fixtures/gitea-workflow/mock-gitea-mcp.js b/fixtures/gitea-workflow/mock-gitea-mcp.js new file mode 100644 index 0000000..2f83fe3 --- /dev/null +++ b/fixtures/gitea-workflow/mock-gitea-mcp.js @@ -0,0 +1,28 @@ +'use strict'; + +// 仅用于离线回归:模拟官方 gitea-mcp 的 JSONL initialize 行为。 +const readline = require('readline'); + +const mode = String(process.env.GITEA_MCP_FIXTURE_MODE || 'success'); +const reader = readline.createInterface({ input: process.stdin }); +reader.on('line', (line) => { + let request; + try { request = JSON.parse(line); } catch { return; } + if (request.method !== 'initialize') return; + if (mode === 'timeout') return; + if (mode === 'exit') process.exit(17); + if (mode === 'error') { + process.stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'fixture handshake failed' }, + })}\n`); + return; + } + process.stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', id: request.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'fixture-gitea-mcp', version: '0.0.0' }, + }, + })}\n`); +}); diff --git a/fixtures/gitea-workflow/queue-recovery.json b/fixtures/gitea-workflow/queue-recovery.json new file mode 100644 index 0000000..eb69676 --- /dev/null +++ b/fixtures/gitea-workflow/queue-recovery.json @@ -0,0 +1,9 @@ +{ + "tasks": [ + {"taskId": "task-queued", "state": "queued", "attempt": 0}, + {"taskId": "task-running", "state": "running", "attempt": 0}, + {"taskId": "task-waiting", "state": "waiting_user", "attempt": 0}, + {"taskId": "task-terminal", "state": "succeeded", "attempt": 0}, + {"taskId": "task-exhausted", "state": "running", "attempt": 1} + ] +} diff --git a/fixtures/gitea-workflow/reply-failure.json b/fixtures/gitea-workflow/reply-failure.json new file mode 100644 index 0000000..8708378 --- /dev/null +++ b/fixtures/gitea-workflow/reply-failure.json @@ -0,0 +1,9 @@ +{ + "taskId": "task-reply-001", + "turnId": "turn-001", + "resourceKey": "default:acme/widget:issue:42", + "mcpAccepted": true, + "mcpCommentVisible": false, + "retryCommentVisible": false, + "restFallbackVisible": true +} diff --git a/fixtures/gitea-workflow/valid-issue-comment.json b/fixtures/gitea-workflow/valid-issue-comment.json new file mode 100644 index 0000000..e59a2d7 --- /dev/null +++ b/fixtures/gitea-workflow/valid-issue-comment.json @@ -0,0 +1,19 @@ +{ + "name": "valid-issue-comment", + "headers": { + "x-gitea-event": "issue_comment", + "x-gitea-delivery": "delivery-issue-001" + }, + "payload": { + "repository": { + "owner": "acme", + "name": "widget", + "clone_url": "https://gitea.example/acme/widget.git", + "default_branch": "main" + }, + "issue": {"number": 42, "pull_request": false}, + "comment": {"id": 101, "body": "@ccweb-bot 请修复登录校验", "user": {"login": "alice"}}, + "sender": {"login": "alice", "is_bot": false} + }, + "expected": {"accepted": true, "resourceKind": "issue", "instruction": "请修复登录校验"} +} diff --git a/fixtures/gitea-workflow/valid-pr-comment.json b/fixtures/gitea-workflow/valid-pr-comment.json new file mode 100644 index 0000000..ef048e3 --- /dev/null +++ b/fixtures/gitea-workflow/valid-pr-comment.json @@ -0,0 +1,23 @@ +{ + "name": "valid-pr-comment", + "headers": { + "x-gitea-event": "pull_request_comment", + "x-gitea-delivery": "delivery-pr-001" + }, + "payload": { + "repository": { + "owner": "acme", + "name": "widget", + "clone_url": "https://gitea.example/acme/widget.git", + "default_branch": "main" + }, + "pull_request": { + "number": 7, + "base": {"ref": "main"}, + "head": {"ref": "feature/login", "repo": {"clone_url": "https://gitea.example/alice/widget.git"}} + }, + "comment": {"id": 202, "body": "请继续处理 @ccweb-bot 的 review 意见", "user": {"login": "bob"}}, + "sender": {"login": "bob", "is_bot": false} + }, + "expected": {"accepted": true, "resourceKind": "pull_request", "base": "main", "head": "feature/login"} +} diff --git a/lib/gitea-mcp-probe.js b/lib/gitea-mcp-probe.js new file mode 100644 index 0000000..c73d627 --- /dev/null +++ b/lib/gitea-mcp-probe.js @@ -0,0 +1,174 @@ +'use strict'; + +/** + * 对线程级 gitea-mcp stdio 配置做最小 MCP initialize 预检。 + * + * 预检只负责判断“命令能否启动并完成协议握手”,不调用 Gitea 工具,也不把 + * stderr、Token 或响应正文写入日志。真正的 Agent 工作仍由 Codex App 按线程 + * 配置启动另一份 gitea-mcp 进程完成。 + */ + +const { spawn: defaultSpawn } = require('child_process'); +const readline = require('readline'); + +const DEFAULT_TIMEOUT_MS = 8000; +const MAX_TIMEOUT_MS = 30000; +const MCP_PROTOCOL_VERSION = '2024-11-05'; + +function safeText(value, max = 512) { + return String(value ?? '').trim().slice(0, max); +} + +function probeError(code, message, details = {}) { + return Object.assign(new Error(message), { code, ...details }); +} + +function normalizeTimeout(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_TIMEOUT_MS; + return Math.min(MAX_TIMEOUT_MS, Math.max(250, Math.floor(numeric))); +} + +function initializeRequest() { + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'cc-web-gitea-preflight', version: '1.0.0' }, + }, + }; +} + +function isInitializeResponse(message) { + return message + && message.jsonrpc === '2.0' + && (message.id === 1 || message.id === '1') + && (Object.prototype.hasOwnProperty.call(message, 'result') + || Object.prototype.hasOwnProperty.call(message, 'error')); +} + +function sendJsonLine(stdin, value) { + stdin.write(`${JSON.stringify(value)}\n`); +} + +/** + * @param {object} options + * @param {string} options.command MCP 命令或绝对路径 + * @param {string[]} [options.args] 命令参数 + * @param {object} [options.env] 线程级环境变量 + * @param {string} [options.cwd] 子进程工作目录 + * @param {number} [options.timeoutMs] 握手超时,默认 8 秒 + * @param {Function} [options.spawnImpl] 测试替身,签名同 child_process.spawn + * @returns {Promise<{ok:true,protocolVersion:string}>} + */ +function probeGiteaMcp(options = {}) { + const command = safeText(options.command, 2048); + if (!command) return Promise.reject(probeError('gitea_mcp_command_empty', 'Gitea MCP 命令为空。')); + const args = Array.isArray(options.args) ? options.args.map((item) => String(item)) : []; + const timeoutMs = normalizeTimeout(options.timeoutMs); + const spawnImpl = typeof options.spawnImpl === 'function' ? options.spawnImpl : defaultSpawn; + const env = { ...process.env, ...(options.env && typeof options.env === 'object' ? options.env : {}) }; + const cwd = safeText(options.cwd, 4096) || process.cwd(); + + return new Promise((resolve, reject) => { + let child; + let settled = false; + let timer = null; + let sawInvalidLine = false; + + const finish = (error, result) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + try { + if (child?.stdin && !child.stdin.destroyed) child.stdin.end(); + } catch {} + try { + if (child && !child.killed) child.kill('SIGTERM'); + } catch {} + if (error) reject(error); + else resolve(result); + }; + + try { + child = spawnImpl(command, args, { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch (error) { + finish(probeError('gitea_mcp_spawn_failed', 'Gitea MCP 进程无法启动。', { cause: error })); + return; + } + + const handleLine = (line) => { + const text = String(line || '').trim(); + if (!text) return; + let message; + try { + message = JSON.parse(text); + } catch { + // stdio 协议要求 JSONL;兼容少数实现的启动提示,最终仍以超时/退出分类。 + sawInvalidLine = true; + return; + } + if (!isInitializeResponse(message)) return; + if (message.error) { + finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialize 握手失败。', { + rpcCode: message.error.code ?? null, + })); + return; + } + const result = message.result && typeof message.result === 'object' ? message.result : {}; + try { + // MCP 客户端在收到 initialize 响应后必须发送 initialized 通知。 + sendJsonLine(child.stdin, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); + } catch (error) { + finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialized 通知发送失败。', { cause: error })); + return; + } + finish(null, { + ok: true, + protocolVersion: safeText(result.protocolVersion, 64) || MCP_PROTOCOL_VERSION, + }); + }; + + if (child?.stdout) { + const lineReader = readline.createInterface({ input: child.stdout }); + lineReader.on('line', handleLine); + child.once?.('close', () => lineReader.close()); + } + child?.once?.('error', (error) => { + finish(probeError('gitea_mcp_spawn_failed', 'Gitea MCP 进程启动失败。', { cause: error })); + }); + child?.once?.('exit', (code, signal) => { + if (settled) return; + finish(probeError('gitea_mcp_start_failed', 'Gitea MCP 进程在握手前退出。', { + exitCode: code ?? null, + signal: signal || null, + invalidOutput: sawInvalidLine, + })); + }); + + timer = setTimeout(() => { + finish(probeError('gitea_mcp_handshake_timeout', `Gitea MCP initialize 握手超时(${timeoutMs}ms)。`)); + }, timeoutMs); + + try { + sendJsonLine(child.stdin, initializeRequest()); + } catch (error) { + finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialize 请求发送失败。', { cause: error })); + } + }); +} + +module.exports = { + DEFAULT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + MCP_PROTOCOL_VERSION, + probeGiteaMcp, +}; diff --git a/lib/gitea-webhook-workspace.js b/lib/gitea-webhook-workspace.js new file mode 100644 index 0000000..2335152 --- /dev/null +++ b/lib/gitea-webhook-workspace.js @@ -0,0 +1,7 @@ +'use strict'; + +// 兼容聚合入口:Webhook 与工作区各自保持独立实现,集成方可按需引用一个模块。 +module.exports = { + ...require('./gitea-webhook'), + ...require('./gitea-workspace'), +}; diff --git a/lib/gitea-webhook.js b/lib/gitea-webhook.js new file mode 100644 index 0000000..74e2e81 --- /dev/null +++ b/lib/gitea-webhook.js @@ -0,0 +1,493 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { parseWorkflowMarker } = require('./gitea-workflow-codex'); + +const DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024; +const DEFAULT_BOT_LOGIN = 'ccweb-bot'; + +function cloneJson(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function safeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function timingSafeHexEqual(expectedHex, receivedHex) { + const expected = Buffer.from(String(expectedHex || '').toLowerCase(), 'hex'); + const received = Buffer.from(String(receivedHex || '').toLowerCase(), 'hex'); + return expected.length > 0 + && expected.length === received.length + && crypto.timingSafeEqual(expected, received); +} + +function normalizeSignature(value) { + const raw = safeString(value).replace(/^sha256[=:]/i, '').trim(); + return /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : ''; +} + +function verifyWebhookSignature(rawBody, signature, secret) { + const configuredSecret = String(secret || ''); + if (!configuredSecret) return false; + const expected = crypto.createHmac('sha256', configuredSecret) + .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''))) + .digest('hex'); + return timingSafeHexEqual(expected, normalizeSignature(signature)); +} + +function getHeader(headers, names) { + const source = headers || {}; + for (const name of names) { + const value = source[name] ?? source[name.toLowerCase()]; + if (Array.isArray(value)) return safeString(value[0]); + if (value !== undefined) return safeString(value); + } + const lowerNames = new Set(names.map((name) => name.toLowerCase())); + for (const [name, value] of Object.entries(source)) { + if (!lowerNames.has(name.toLowerCase())) continue; + return Array.isArray(value) ? safeString(value[0]) : safeString(value); + } + return ''; +} + +function atomicWriteJson(filePath, value) { + const target = path.resolve(filePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + try { + fs.renameSync(tempPath, target); + } finally { + try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} + } +} + +class DeliveryStore { + constructor(options = {}) { + this.filePath = options.filePath ? path.resolve(options.filePath) : ''; + this.maxEntries = Number.isSafeInteger(options.maxEntries) && options.maxEntries > 0 + ? options.maxEntries : 10_000; + this.entries = new Map(); + this.load(); + } + + load() { + if (!this.filePath || !fs.existsSync(this.filePath)) return; + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + const source = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed.entries || parsed : {}; + for (const [key, value] of Object.entries(source)) { + if (key && value && typeof value === 'object') this.entries.set(key, value); + } + } catch { + // 损坏的去重文件不应阻止服务启动;新请求会覆盖为可恢复的合法文件。 + this.entries.clear(); + } + } + + get(key) { + const value = this.entries.get(String(key)); + return value ? cloneJson(value) : null; + } + + has(key) { + return this.entries.has(String(key)); + } + + put(key, value) { + const normalizedKey = String(key); + this.entries.set(normalizedKey, cloneJson(value)); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + this.entries.delete(oldest); + } + if (this.filePath) { + atomicWriteJson(this.filePath, { + schemaVersion: 1, + entries: Object.fromEntries(this.entries), + }); + } + } + + claimDelivery(key, value = {}) { + const existing = this.get(key); + if (existing) return { duplicate: true, record: existing }; + this.put(key, value); + return { duplicate: false, record: this.get(key) }; + } + + getDelivery(key) { return this.get(key); } +} + +function repositoryFromPayload(payload) { + const repository = payload?.repository || payload?.repo || {}; + const owner = repository.owner || repository.organization || {}; + const ownerName = typeof owner === 'string' + ? safeString(owner) + : safeString(owner.login || owner.username || owner.name || repository.owner_name); + const repoName = safeString(repository.name || repository.repo_name); + const fullName = safeString(repository.full_name || (ownerName && repoName ? `${ownerName}/${repoName}` : '')); + const parts = fullName.split('/').filter(Boolean); + const normalizedOwner = ownerName || parts.slice(0, -1).join('/'); + const normalizedRepo = repoName || parts.at(-1) || ''; + if (!normalizedOwner || !normalizedRepo || normalizedOwner.includes('/') || normalizedRepo.includes('/')) { + return null; + } + return { + owner: normalizedOwner, + name: normalizedRepo, + fullName: `${normalizedOwner}/${normalizedRepo}`, + cloneUrl: safeString(repository.clone_url || repository.cloneUrl || repository.html_url || ''), + defaultBranch: safeString(repository.default_branch || repository.defaultBranch || ''), + private: repository.private === true, + }; +} + +function resourceFromPayload(payload, eventName) { + const issue = payload?.issue || payload?.pull_request || payload?.pullRequest || {}; + const pullRequest = payload?.pull_request || payload?.pullRequest || issue?.pull_request; + const number = Number(issue.number || payload?.number || pullRequest?.number); + if (!Number.isSafeInteger(number) || number <= 0) return null; + const isPullRequest = eventName.includes('pull_request') + || !!issue.pull_request + || !!payload?.pull_request + || !!payload?.pullRequest; + const kind = isPullRequest ? 'pull_request' : 'issue'; + return { + kind, + number, + key: `${kind}:${number}`, + title: safeString(issue.title || pullRequest?.title), + baseRef: safeString(pullRequest?.base?.ref || pullRequest?.base_ref), + headRef: safeString(pullRequest?.head?.ref || pullRequest?.head_ref), + headRepo: safeString(pullRequest?.head?.repo?.full_name || pullRequest?.head_repo), + }; +} + +function commentFromPayload(payload) { + const comment = payload?.comment || payload?.review?.comment || {}; + const body = typeof comment.body === 'string' + ? comment.body + : (typeof payload?.comment_body === 'string' ? payload.comment_body : ''); + if (!body) return null; + const author = comment.user || comment.author || payload?.sender || payload?.user || {}; + return { + id: String(comment.id || comment.number || payload?.comment_id || '').trim(), + body, + author: { + id: author.id ?? null, + login: safeString(author.login || author.username || author.name), + type: safeString(author.type), + }, + createdAt: safeString(comment.created_at || comment.createdAt || payload?.created_at), + updatedAt: safeString(comment.updated_at || comment.updatedAt || payload?.updated_at), + }; +} + +function normalizeEventName(headerValue, payload) { + const header = safeString(headerValue).toLowerCase().replace(/[-\s]/g, '_'); + const action = safeString(payload?.action).toLowerCase(); + if (['issue_comment', 'issues_comment', 'pull_request_comment', 'pullrequest_comment'].includes(header)) { + return header.includes('pull') ? 'pull_request_comment' : 'issue_comment'; + } + // 某些 Gitea 版本把普通 Issue 评论复用为 issues + comment 字段; + // 只有存在 comment 时才归一化,避免新建 Issue 被误触发。 + if (header === 'issues' && (action === 'comment' || payload?.comment)) return 'issue_comment'; + if (header === 'pull_request' && (['comment', 'review_comment'].includes(action) || payload?.comment)) return 'pull_request_comment'; + return header; +} + +function parseBotMention(body, botLogin = DEFAULT_BOT_LOGIN) { + const login = safeString(botLogin) || DEFAULT_BOT_LOGIN; + const mention = new RegExp(`(^|[^\\w@])@${escapeRegExp(login)}(?=$|[^\\w-])`, 'i'); + const match = mention.exec(String(body || '')); + if (!match) return null; + const before = String(body).slice(0, match.index); + const after = String(body).slice(match.index + match[0].length); + const instruction = `${before}${after}` + .replace(/^[\s::,,、-]+/, '') + .replace(/[\s]+$/, '') + .trim(); + return { + botLogin: login, + mention: `@${login}`, + instruction, + index: match.index + match[1].length, + }; +} + +function isBotAuthor(author, botIdentity = {}) { + const login = safeString(author?.login).toLowerCase(); + const configuredLogin = safeString(botIdentity.login || botIdentity.username || DEFAULT_BOT_LOGIN).toLowerCase(); + if (login && configuredLogin && login === configuredLogin) return true; + if (botIdentity.id !== undefined && botIdentity.id !== null && author?.id !== null && author?.id !== undefined) { + return String(botIdentity.id) === String(author.id); + } + return false; +} + +function normalizeWebhookEvent({ payload, headers = {}, instanceId = 'default', botIdentity }) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return { ignored: true, reason: 'invalid_payload' }; + } + const eventName = normalizeEventName(getHeader(headers, ['x-gitea-event', 'x-event-key', 'x-github-event']), payload); + if (!['issue_comment', 'pull_request_comment'].includes(eventName)) { + return { ignored: true, reason: 'unsupported_event', eventName }; + } + const comment = commentFromPayload(payload); + if (!comment) return { ignored: true, reason: 'not_comment', eventName }; + // 带完整关联标识的 cc-web 回帖即使 Gitea 版本未携带作者字段,也不得再次触发。 + if (parseWorkflowMarker(comment.body)) { + return { ignored: true, reason: 'workflow_marker_comment', eventName, commentId: comment.id || null }; + } + if (isBotAuthor(comment.author, botIdentity)) { + return { ignored: true, reason: 'bot_self_comment', eventName, commentId: comment.id || null }; + } + const mention = parseBotMention(comment.body, botIdentity?.login || DEFAULT_BOT_LOGIN); + if (!mention) return { ignored: true, reason: 'missing_mention', eventName, commentId: comment.id || null }; + const repository = repositoryFromPayload(payload); + if (!repository) return { ignored: true, reason: 'repository_unresolved', eventName, commentId: comment.id || null }; + const resource = resourceFromPayload(payload, eventName); + if (!resource) return { ignored: true, reason: 'resource_unresolved', eventName, commentId: comment.id || null }; + const sender = payload.sender || payload.user || null; + return { + ignored: false, + eventName, + eventType: eventName, + instanceId: safeString(instanceId) || 'default', + deliveryId: getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || safeString(payload.delivery_id), + repository, + repo: repository, + resource, + refs: { base: resource.baseRef || null, head: resource.headRef || null }, + comment, + mention, + sender, + actor: { + login: safeString(sender?.login || sender?.username || sender?.name), + id: sender?.id ?? null, + isBot: sender?.is_bot === true || String(sender?.type || '').toLowerCase() === 'bot', + }, + receivedAt: new Date().toISOString(), + }; +} + +function createTaskId(deliveryKey) { + return `gitea-task-${crypto.createHash('sha256').update(String(deliveryKey)).digest('hex').slice(0, 24)}`; +} + +function readRawBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + req.on('data', (chunk) => { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > maxBytes) { + fail(Object.assign(new Error('Webhook 请求体过大。'), { code: 'body_too_large' })); + try { req.destroy(); } catch {} + return; + } + chunks.push(buffer); + }); + req.on('end', () => { + if (settled) return; + settled = true; + resolve(Buffer.concat(chunks)); + }); + req.on('error', fail); + }); +} + +function readDeliveryRecord(store, key) { + if (typeof store?.get === 'function') return store.get(key); + if (typeof store?.getDelivery === 'function') return store.getDelivery(key); + return null; +} + +function claimDeliveryRecord(store, key, record) { + if (typeof store?.claimDelivery === 'function') { + return store.claimDelivery(key, record); + } + if (typeof store?.put === 'function') { + store.put(key, record); + return { duplicate: false, record }; + } + throw new TypeError('deliveryStore 必须提供 get/put 或 getDelivery/claimDelivery。'); +} + +function createGiteaWebhookReceiver(options = {}) { + let secret = options.secret ?? process.env.CC_WEB_GITEA_WEBHOOK_SECRET ?? ''; + let botToken = options.botToken ?? ''; + const instanceId = safeString(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID) || 'default'; + const botIdentity = { + login: safeString(options.botLogin || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN) || DEFAULT_BOT_LOGIN, + id: options.botId ?? process.env.CC_WEB_GITEA_BOT_ID ?? process.env.GITEA_BOT_ID ?? null, + }; + const maxBodyBytes = Number.isSafeInteger(options.maxBodyBytes) && options.maxBodyBytes > 0 + ? options.maxBodyBytes : DEFAULT_MAX_BODY_BYTES; + const deliveryStore = options.deliveryStore || new DeliveryStore({ + filePath: options.deliveryFile || process.env.CC_WEB_GITEA_DELIVERY_FILE, + }); + const onTask = typeof options.onTask === 'function' ? options.onTask : async () => {}; + const inflight = new Set(); + + async function processWebhook({ headers = {}, rawBody }) { + const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || '')); + if (options.requireBotToken && !String(botToken || '').trim()) { + return { statusCode: 503, payload: { ok: false, error: 'gitea_workflow_not_configured' } }; + } + const signature = getHeader(headers, ['x-gitea-signature', 'x-hub-signature-256', 'x-signature']); + if (secret && !verifyWebhookSignature(body, signature, secret)) { + return { statusCode: 401, payload: { ok: false, error: 'invalid_signature' } }; + } + let payload; + try { + payload = JSON.parse(body.toString('utf8')); + } catch { + return { statusCode: 400, payload: { ok: false, error: 'invalid_json' } }; + } + const deliveryId = getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) + || safeString(payload.delivery_id); + if (!deliveryId) return { statusCode: 400, payload: { ok: false, error: 'missing_delivery_id' } }; + const deliveryKey = `${instanceId}:${deliveryId}`; + const existing = readDeliveryRecord(deliveryStore, deliveryKey); + if (existing) { + return { + statusCode: 200, + payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: existing.taskId || null, reason: existing.reason || null }, + record: existing, + }; + } + const event = normalizeWebhookEvent({ payload, headers, instanceId, botIdentity }); + event.deliveryId = deliveryId; + const record = { + deliveryKey, + deliveryId, + instanceId, + taskId: event.ignored ? null : createTaskId(deliveryKey), + status: event.ignored ? 'ignored' : 'accepted', + reason: event.reason || null, + eventName: event.eventName || null, + receivedAt: event.receivedAt || new Date().toISOString(), + }; + const claimed = claimDeliveryRecord(deliveryStore, deliveryKey, record); + if (claimed?.duplicate) { + const duplicateRecord = claimed.record || readDeliveryRecord(deliveryStore, deliveryKey) || record; + return { + statusCode: 200, + payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: duplicateRecord.taskId || null }, + record: duplicateRecord, + }; + } + if (event.ignored) { + return { statusCode: 200, payload: { ok: true, ignored: true, status: 'ignored', reason: event.reason }, record, event }; + } + const task = { + taskId: record.taskId, + deliveryKey, + deliveryId, + instanceId, + event, + receivedAt: record.receivedAt, + }; + // Webhook 先快速确认,工作区 clone/fetch 等副作用由上层异步处理。 + const taskPromise = Promise.resolve().then(() => onTask(cloneJson(task))).catch((error) => { + if (typeof options.onTaskError === 'function') { + try { options.onTaskError(error, task); } catch {} + } + }).finally(() => inflight.delete(taskPromise)); + inflight.add(taskPromise); + return { + statusCode: 202, + payload: { ok: true, accepted: true, state: 'queued', status: 'accepted', deliveryId, taskId: record.taskId }, + record, + event, + task, + }; + } + + async function handle(req, res) { + let result; + try { + const rawBody = await readRawBody(req, maxBodyBytes); + result = await processWebhook({ headers: req.headers || {}, rawBody }); + } catch (error) { + const statusCode = error?.code === 'body_too_large' ? 413 : 400; + result = { statusCode, payload: { ok: false, error: error?.code || 'invalid_request' } }; + } + if (!res.headersSent) { + res.writeHead(result.statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-cache', + }); + res.end(JSON.stringify(result.payload)); + } + return result; + } + + async function drain() { + await Promise.all(Array.from(inflight)); + } + + function updateConfig(next = {}) { + if (Object.prototype.hasOwnProperty.call(next, 'secret')) secret = String(next.secret || ''); + if (Object.prototype.hasOwnProperty.call(next, 'botToken')) botToken = String(next.botToken || ''); + if (Object.prototype.hasOwnProperty.call(next, 'botLogin')) { + botIdentity.login = safeString(next.botLogin) || DEFAULT_BOT_LOGIN; + } + if (Object.prototype.hasOwnProperty.call(next, 'botId')) botIdentity.id = next.botId ?? null; + return { + webhookSecretConfigured: Boolean(secret), + botTokenConfigured: Boolean(botToken), + botLogin: botIdentity.login, + botId: botIdentity.id, + }; + } + + return { handle, processWebhook, drain, updateConfig, deliveryStore, instanceId, botIdentity }; +} + +function createGiteaWebhookRoute(receiver, options = {}) { + if (!receiver || typeof receiver.handle !== 'function') { + throw new TypeError('receiver.handle 必须是函数。'); + } + const endpoint = String(options.path || '/api/gitea/webhook'); + return function handleGiteaWebhookRoute(req, res, urlLike) { + let pathname = typeof urlLike === 'string' ? urlLike : urlLike?.pathname; + if (!pathname) { + try { pathname = new URL(req.url || '/', 'http://localhost').pathname; } catch { pathname = ''; } + } + if (req.method !== 'POST' || pathname !== endpoint) return false; + receiver.handle(req, res); + return true; + }; +} + +module.exports = { + DEFAULT_MAX_BODY_BYTES, + DeliveryStore, + createDeliveryStore: (options) => new DeliveryStore(options), + createGiteaWebhookRoute, + createGiteaWebhookReceiver, + createTaskId, + normalizeWebhookEvent, + parseBotMention, + verifyWebhookSignature, +}; diff --git a/lib/gitea-workflow-codex.js b/lib/gitea-workflow-codex.js new file mode 100644 index 0000000..65a3683 --- /dev/null +++ b/lib/gitea-workflow-codex.js @@ -0,0 +1,638 @@ +'use strict'; + +/** + * Gitea Webhook 工作流与 Codex App 之间的薄适配层。 + * + * 该模块不负责队列或业务存储,只维护一次 task/turn 的协议状态,方便 + * server.js、持久化层和 Gitea REST/MCP 客户端分别注入实现。所有外部副作用 + * 都通过 callbacks 注入,因而可以用协议 mock 做确定性回归。 + */ + +const DEFAULT_GITEA_MCP_COMMAND = 'gitea-mcp'; +const WORKFLOW_MARKER_NAME = 'ccweb-gitea'; +const WORKFLOW_MARKER_VERSION = '1'; +const DEFAULT_REPLY_RETRY_LIMIT = 1; + +const WORKFLOW_STATES = Object.freeze({ + QUEUED: 'queued', + RUNNING: 'running', + WAITING_USER: 'waiting_user', + VERIFYING_REPLY: 'verifying_reply', + RETRYING_REPLY: 'retrying_reply', + SUCCEEDED: 'succeeded', + SUCCEEDED_WITH_REST_FALLBACK: 'succeeded_with_rest_fallback', + FAILED: 'failed', + FAILED_REPLY: 'failed_reply', +}); + +const TURN_STATES = Object.freeze({ + STARTED: 'started', + COMPLETED: 'completed', + WAITING_USER: 'waiting_user', + FAILED: 'failed', + INTERRUPTED: 'interrupted', +}); + +function cleanText(value, max = 512) { + const text = String(value ?? '').trim(); + return text.length > max ? text.slice(0, max) : text; +} + +function requiredText(value, field, max = 512) { + const text = cleanText(value, max); + if (!text) throw new TypeError(`${field} 不能为空。`); + return text; +} + +function clone(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function isoNow(now) { + const value = typeof now === 'function' ? now() : now; + const date = value instanceof Date ? value : new Date(value || Date.now()); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +function normalizeHost(value) { + const host = requiredText(value, 'Gitea host', 2048).replace(/\/+$/, ''); + let parsed; + try { + parsed = new URL(host); + } catch { + throw new TypeError('Gitea host 必须是绝对 URL。'); + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + throw new TypeError('Gitea host 只允许 http/https URL,不能携带凭据。'); + } + return parsed.toString().replace(/\/+$/, ''); +} + +function buildGiteaMcpServerConfig(options = {}) { + const host = normalizeHost(options.host || options.giteaHost); + const accessToken = requiredText(options.accessToken || options.token, 'Gitea access token', 4096); + const command = cleanText(options.command || DEFAULT_GITEA_MCP_COMMAND, 512); + if (!command) throw new TypeError('gitea-mcp command 不能为空。'); + + const extraArgs = Array.isArray(options.extraArgs) + ? options.extraArgs.map((item) => String(item)) + : []; + const config = { + type: 'stdio', + command, + args: ['-t', 'stdio', '-H', host, ...extraArgs], + // host 同时放在标准环境变量中,便于不同版本的官方 gitea-mcp 兼容; + // token 只存在于本线程 config,不写入 app-server 进程环境。 + env: { + GITEA_HOST: host, + GITEA_ACCESS_TOKEN: accessToken, + }, + }; + if (Number.isFinite(options.startupTimeoutSec)) { + config.startup_timeout_sec = Math.max(1, Number(options.startupTimeoutSec)); + } + if (Number.isFinite(options.toolTimeoutSec)) { + config.tool_timeout_sec = Math.max(1, Number(options.toolTimeoutSec)); + } + return config; +} + +// 兼容工作流核心服务使用的旧命名;新代码优先使用 +// buildGiteaMcpServerConfig/buildGiteaThreadConfig。 +function buildGiteaMcpConfig(config = {}, options = {}) { + const source = config.gitea && typeof config.gitea === 'object' ? config.gitea : config; + return buildGiteaMcpServerConfig({ + host: options.host || source.host, + accessToken: options.token || options.accessToken || source.token || source.accessToken, + command: options.command || source.mcpCommand || DEFAULT_GITEA_MCP_COMMAND, + extraArgs: options.args && Array.isArray(options.args) + ? options.args.filter((item, index) => !(index === 0 && item === '-t') && !(index === 1 && item === 'stdio')) + : [], + }); +} + +function buildGiteaThreadConfig(options = {}) { + const cwd = requiredText(options.cwd || options.workspacePath, '工作区 cwd', 4096); + const gitea = buildGiteaMcpServerConfig(options); + return { + cwd, + config: { + 'mcp_servers.gitea': gitea, + }, + // 便于调用方在需要时直接合并到 thread/start 参数;server.js 使用 config。 + mcpServer: gitea, + }; +} + +function mergeGiteaThreadConfig(baseConfig = {}, options = {}) { + const thread = buildGiteaThreadConfig(options); + return { + ...baseConfig, + ...thread.config, + }; +} + +function extractTurnId(value = {}) { + if (typeof value === 'string' || typeof value === 'number') return cleanText(value, 256) || null; + if (!value || typeof value !== 'object') return null; + const params = value.params && typeof value.params === 'object' ? value.params : value; + const candidates = [ + params.turnId, + params.turn_id, + params.turn?.id, + params.item?.turnId, + params.item?.turn_id, + params.item?.turn?.id, + params.event?.turnId, + ]; + for (const candidate of candidates) { + const turnId = cleanText(candidate, 256); + if (turnId) return turnId; + } + return null; +} + +function extractThreadId(value = {}) { + if (typeof value === 'string' || typeof value === 'number') return cleanText(value, 256) || null; + if (!value || typeof value !== 'object') return null; + const params = value.params && typeof value.params === 'object' ? value.params : value; + const candidates = [ + params.threadId, + params.thread_id, + params.thread?.id, + params.item?.threadId, + params.item?.thread_id, + ]; + for (const candidate of candidates) { + const threadId = cleanText(candidate, 256); + if (threadId) return threadId; + } + return null; +} + +function escapeMarkerValue(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function unescapeMarkerValue(value) { + return String(value ?? '') + .replaceAll('"', '"') + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'); +} + +function buildWorkflowMarker(input = {}) { + const taskId = requiredText(input.taskId, 'taskId', 256); + const turnId = requiredText(input.turnId, 'turnId', 256); + const resourceKey = requiredText(input.resourceKey, 'resourceKey', 1024); + const kind = cleanText(input.kind || 'final', 64) || 'final'; + return ``; +} + +function parseWorkflowMarker(body) { + const text = String(body || ''); + const markerMatch = text.match(new RegExp(``, 'i')); + if (!markerMatch) return null; + const attrs = {}; + const attrPattern = /([a-z][\w-]*)\s*=\s*"([^"]*)"/gi; + let match; + while ((match = attrPattern.exec(markerMatch[1]))) attrs[match[1]] = unescapeMarkerValue(match[2]); + if (attrs.v !== WORKFLOW_MARKER_VERSION) return null; + if (!attrs.taskId || !attrs.turnId || !attrs.resourceKey) return null; + return { + version: attrs.v, + taskId: attrs.taskId, + turnId: attrs.turnId, + resourceKey: attrs.resourceKey, + kind: attrs.kind || 'final', + }; +} + +function botCandidate(comment = {}) { + return comment.user || comment.author || comment.sender || comment.actor || comment.owner || {}; +} + +function normalizeBotIdentity(identity = {}) { + if (typeof identity === 'string') return { login: cleanText(identity, 256).toLowerCase() }; + return { + id: identity.id === undefined || identity.id === null ? null : String(identity.id), + login: cleanText(identity.login || identity.username || identity.name, 256).toLowerCase() || null, + }; +} + +function isBotComment(comment = {}, identity = {}) { + const candidate = botCandidate(comment); + const bot = normalizeBotIdentity(identity); + if (candidate.isBot === true || candidate.is_bot === true || String(candidate.type || '').toLowerCase() === 'bot') return true; + if (bot.id && candidate.id !== undefined && candidate.id !== null && String(candidate.id) === bot.id) return true; + const login = cleanText(candidate.login || candidate.username || candidate.name, 256).toLowerCase(); + return !!(bot.login && login && login === bot.login); +} + +function normalizeComment(comment = {}) { + const candidate = botCandidate(comment); + const resource = comment.resource || comment.issue || comment.pull_request || {}; + return { + id: comment.id ?? comment.commentId ?? null, + body: String(comment.body || comment.content || ''), + resourceKey: cleanText(comment.resourceKey || resource.resourceKey, 1024) || null, + user: { + id: candidate.id ?? null, + login: cleanText(candidate.login || candidate.username || candidate.name, 256) || null, + isBot: candidate.isBot === true || candidate.is_bot === true || String(candidate.type || '').toLowerCase() === 'bot', + }, + raw: comment, + }; +} + +function matchWorkflowReply(comment, expected = {}, botIdentity = {}) { + const normalized = normalizeComment(comment); + if (!isBotComment(comment, botIdentity)) return false; + const marker = parseWorkflowMarker(normalized.body); + if (!marker) return false; + if (!['final', 'fallback'].includes(String(marker.kind || 'final').toLowerCase())) return false; + const expectedTurnIds = Array.isArray(expected.turnIds) + ? expected.turnIds.map((value) => cleanText(value, 256)).filter(Boolean) + : [cleanText(expected.turnId, 256)].filter(Boolean); + return marker.taskId === cleanText(expected.taskId, 256) + && expectedTurnIds.includes(marker.turnId) + && marker.resourceKey === cleanText(expected.resourceKey, 1024); +} + +function findWorkflowReply(comments, expected = {}, botIdentity = {}) { + if (!Array.isArray(comments)) return null; + return comments.find((comment) => matchWorkflowReply(comment, expected, botIdentity)) || null; +} + +function normalizeVerification(result, expected, botIdentity) { + if (Array.isArray(result)) { + const comment = findWorkflowReply(result, expected, botIdentity); + return comment ? { status: 'confirmed', comment } : { status: 'missing', comment: null }; + } + if (result && typeof result === 'object') { + if (Array.isArray(result.comments)) return normalizeVerification(result.comments, expected, botIdentity); + if (result.status === 'unknown' || result.unknown === true) return { status: 'unknown', comment: null }; + if (result.confirmed === true || result.found === true || result.accepted === true) { + if (result.comment && !matchWorkflowReply(result.comment, expected, botIdentity)) { + return { status: 'missing', comment: null }; + } + return { status: 'confirmed', comment: result.comment || null, commentId: result.commentId || result.id || null }; + } + if (result.error || result.status === 'error') return { status: 'unknown', error: result.error || result.message || 'reply verification failed' }; + } + if (result === true) return { status: 'confirmed', comment: null }; + return { status: 'missing', comment: null }; +} + +function isWaitingUserEvent(notification = {}) { + const method = String(notification.method || notification.type || '').toLowerCase(); + const params = notification.params && typeof notification.params === 'object' ? notification.params : notification; + if (method.includes('requestuserinput') || method.includes('waiting_user') || method.includes('waitinguser')) return true; + return String(params.status || params.turn?.status || params.reason || '').toLowerCase() === 'waiting_user'; +} + +function buildWaitingUserPrompt(comment = {}) { + const body = String(comment.body || comment.content || '').trim(); + return body || '用户已继续评论,请读取本条评论并继续处理。'; +} + +function buildReplyRetryPrompt(task, finalText, marker) { + return [ + '[ccweb-gitea hidden reply retry]', + '只补发上一轮最终回执,不要重复修改代码、执行写操作或重新研究。', + `resourceKey=${task.resourceKey}`, + `taskId=${task.taskId}`, + `turnId=${marker.turnId}`, + '上一轮最终文本:', + String(finalText || '').trim(), + marker.text, + ].join('\n'); +} + +function encodeMarker(input = {}) { + return buildWorkflowMarker({ + taskId: input.taskId, + turnId: input.turnId, + resourceKey: input.resourceKey, + kind: input.kind || input.state || 'final', + }); +} + +function buildWorkflowPrompt(task = {}) { + const instruction = String(task.instruction || task.prompt || '').trim(); + const resource = String(task.resourceKey || '').trim(); + const workspace = String(task.workspacePath || task.cwd || '').trim(); + const marker = task.turnId && task.taskId && resource + ? encodeMarker({ taskId: task.taskId, turnId: task.turnId, resourceKey: resource, state: 'final' }) + : ''; + const waitingMarker = task.turnId && task.taskId && resource + ? encodeMarker({ taskId: task.taskId, turnId: task.turnId, resourceKey: resource, kind: 'waiting_user' }) + : ''; + return [ + '你正在执行 cc-web Gitea Workflow 任务。', + '请在当前工作区完成用户请求;Gitea 是唯一主交互入口。', + resource ? `资源:${resource}` : '', + workspace ? `工作区:${workspace}` : '', + instruction ? `用户指令:\n${instruction}` : '用户未提供额外指令,请先读取资源上下文。', + '完成后必须使用官方 gitea-mcp 在原 Issue/PR 回帖;最终摘要正文末尾保留 final 隐藏标记。', + '如果需要用户补充信息或遇到阻塞,请只在原 Issue/PR 提问并在正文末尾保留 waiting_user 隐藏标记,然后停止,不要继续修改。', + `final 隐藏标记:${marker}`, + `waiting_user 隐藏标记:${waitingMarker}`, + ].filter(Boolean).join('\n\n'); +} + +function classifyReplyComments(comments, expected = {}, botIdentity = {}) { + const verification = normalizeVerification(comments, expected, botIdentity); + return { + status: verification.status, + comment: verification.comment || null, + commentId: verification.commentId || verification.comment?.id || null, + error: verification.error || null, + }; +} + +function createGiteaWorkflowCodex(options = {}) { + const now = options.now || (() => new Date()); + const botIdentity = normalizeBotIdentity(options.botIdentity || options.botLogin || {}); + const replyRetryLimit = Number.isFinite(options.replyRetryLimit) + ? Math.max(0, Math.floor(options.replyRetryLimit)) + : DEFAULT_REPLY_RETRY_LIMIT; + const tasks = new Map(); + const sessions = new Map(); + + function emit(task, event, extra = {}) { + task.updatedAt = isoNow(now); + const snapshot = { ...clone(task), event, ...clone(extra) }; + if (typeof options.onStateChange === 'function') options.onStateChange(snapshot); + return snapshot; + } + + function createTask(input = {}) { + const taskId = requiredText(input.taskId, 'taskId', 256); + const sessionKey = requiredText(input.sessionKey, 'sessionKey', 1024); + const resourceKey = requiredText(input.resourceKey, 'resourceKey', 1024); + const current = tasks.get(taskId); + if (current) return clone(current); + const task = { + taskId, + sessionKey, + resourceKey, + threadId: cleanText(input.threadId, 256) || null, + parentTaskId: cleanText(input.parentTaskId, 256) || null, + state: input.state || WORKFLOW_STATES.QUEUED, + turnId: null, + turnState: null, + turnKind: 'normal', + replyAttempt: 0, + replyRetryTurnId: null, + finalText: '', + waitingUser: null, + lastVerification: null, + createdAt: isoNow(now), + updatedAt: isoNow(now), + metadata: clone(input.metadata || {}), + }; + tasks.set(taskId, task); + if (task.threadId) sessions.set(sessionKey, { sessionKey, threadId: task.threadId, updatedAt: task.updatedAt }); + return clone(task); + } + + function getTask(taskId) { + return clone(tasks.get(cleanText(taskId, 256)) || null); + } + + function listTasks() { + return Array.from(tasks.values(), clone); + } + + function threadConfig(input = {}) { + return buildGiteaThreadConfig(input); + } + + function attachThread(taskId, threadId) { + const task = tasks.get(requiredText(taskId, 'taskId', 256)); + const nextThreadId = requiredText(threadId, 'threadId', 256); + if (task.threadId && task.threadId !== nextThreadId) throw new Error('同一工作流任务不能切换 threadId。'); + task.threadId = nextThreadId; + sessions.set(task.sessionKey, { sessionKey: task.sessionKey, threadId: nextThreadId, updatedAt: isoNow(now) }); + return emit(task, 'thread_attached'); + } + + function handleTurnStarted(input = {}) { + const task = tasks.get(requiredText(input.taskId, 'taskId', 256)); + const turnId = requiredText(input.turnId || extractTurnId(input), 'turnId', 256); + const threadId = cleanText(input.threadId || extractThreadId(input), 256) || task.threadId; + if (threadId) attachThread(task.taskId, threadId); + if (task.turnId && task.turnId !== turnId && task.state === WORKFLOW_STATES.RUNNING) { + throw new Error('任务已有运行中的 turnId。'); + } + task.turnId = turnId; + task.turnKind = input.kind === 'reply_retry' ? 'reply_retry' : 'normal'; + task.turnState = TURN_STATES.STARTED; + task.state = WORKFLOW_STATES.RUNNING; + if (task.turnKind === 'reply_retry') task.replyRetryTurnId = turnId; + return emit(task, 'turn_started', { turnId, threadId: task.threadId }); + } + + function handleTurnEvent(input = {}) { + const task = tasks.get(requiredText(input.taskId, 'taskId', 256)); + const turnId = extractTurnId(input.notification || input) || cleanText(input.turnId, 256) || task.turnId; + if (turnId && task.turnId && turnId !== task.turnId) return { ok: false, code: 'turn_id_mismatch', task: clone(task) }; + if (turnId && !task.turnId) task.turnId = turnId; + if (isWaitingUserEvent(input.notification || input)) { + task.turnState = TURN_STATES.WAITING_USER; + task.state = WORKFLOW_STATES.WAITING_USER; + task.waitingUser = clone(input.notification?.params || input.params || input); + return emit(task, 'waiting_user', { turnId: task.turnId }); + } + if (String(input.method || input.notification?.method || '').toLowerCase() === 'turn/started') { + task.turnState = TURN_STATES.STARTED; + task.state = WORKFLOW_STATES.RUNNING; + } + return emit(task, 'turn_event', { turnId: task.turnId, method: input.method || input.notification?.method || null }); + } + + async function verifyReply(task, turnId, source) { + const markerText = buildWorkflowMarker({ taskId: task.taskId, turnId, resourceKey: task.resourceKey, kind: 'final' }); + const expected = { taskId: task.taskId, turnId, resourceKey: task.resourceKey }; + let result; + try { + if (typeof options.verifyReply === 'function') { + result = await options.verifyReply({ task: clone(task), expected, source, marker: markerText }); + } else if (typeof options.listComments === 'function') { + result = await options.listComments({ task: clone(task), expected, source }); + } else { + result = { status: 'unknown', error: '未配置 Gitea 回执查询器。' }; + } + } catch (error) { + result = { status: 'unknown', error: error?.message || String(error || '') }; + } + const normalized = normalizeVerification(result, expected, botIdentity); + task.lastVerification = { ...normalized, source, turnId, checkedAt: isoNow(now) }; + return normalized; + } + + async function handleTurnCompleted(input = {}) { + const task = tasks.get(requiredText(input.taskId, 'taskId', 256)); + const turnId = cleanText(input.turnId || extractTurnId(input), 256) || task.turnId; + if (!turnId || (task.turnId && turnId !== task.turnId)) return { ok: false, code: 'turn_id_mismatch', task: clone(task) }; + task.turnId = turnId; + if (input.waitingUser || isWaitingUserEvent(input.notification || input)) { + task.turnState = TURN_STATES.WAITING_USER; + task.state = WORKFLOW_STATES.WAITING_USER; + task.waitingUser = clone(input.waitingUser || input.notification?.params || input.params || {}); + return emit(task, 'waiting_user', { turnId }); + } + const status = String(input.status || input.stopReason || 'completed').toLowerCase(); + if (['failed', 'error'].includes(status)) { + task.turnState = TURN_STATES.FAILED; + task.state = WORKFLOW_STATES.FAILED; + return emit(task, 'turn_failed', { turnId }); + } + if (['interrupted', 'cancelled', 'canceled', 'aborted'].includes(status)) { + task.turnState = TURN_STATES.INTERRUPTED; + task.state = WORKFLOW_STATES.FAILED; + return emit(task, 'turn_interrupted', { turnId }); + } + + task.turnState = TURN_STATES.COMPLETED; + task.finalText = String(input.finalText || task.finalText || '').trim(); + task.state = WORKFLOW_STATES.VERIFYING_REPLY; + const first = await verifyReply(task, turnId, 'mcp'); + if (first.status === 'confirmed') { + task.state = WORKFLOW_STATES.SUCCEEDED; + return emit(task, 'reply_confirmed', { turnId, source: 'mcp', commentId: first.commentId || first.comment?.id || null }); + } + if (first.status === 'unknown') { + // 查询异常代表外部状态未知;不能据此再次执行修改、补发或 REST 覆盖。 + task.state = WORKFLOW_STATES.FAILED_REPLY; + return emit(task, 'reply_verification_unknown', { turnId, verification: first }); + } + + if (task.replyAttempt < replyRetryLimit && typeof options.startTurn === 'function') { + task.replyAttempt += 1; + task.state = WORKFLOW_STATES.RETRYING_REPLY; + const marker = { + turnId, + text: buildWorkflowMarker({ taskId: task.taskId, turnId, resourceKey: task.resourceKey, kind: 'final' }), + }; + const prompt = buildReplyRetryPrompt(task, task.finalText, marker); + try { + const retry = await options.startTurn({ + task: clone(task), + threadId: task.threadId, + kind: 'reply_retry', + hidden: true, + prompt, + marker: marker.text, + }); + const retryTurnId = cleanText(retry?.turnId || extractTurnId(retry), 256); + if (!retryTurnId) throw new Error('补触发未返回 turnId。'); + task.turnId = retryTurnId; + task.replyRetryTurnId = retryTurnId; + task.turnKind = 'reply_retry'; + task.turnState = TURN_STATES.STARTED; + task.state = WORKFLOW_STATES.RUNNING; + return emit(task, 'reply_retry_started', { turnId: retryTurnId, previousTurnId: turnId, threadId: task.threadId }); + } catch (error) { + task.lastVerification = { ...task.lastVerification, retryError: error?.message || String(error || '') }; + } + } + + // 补触发失败或补触发后的 turn 完成会落到这里;REST 是唯一最后出口。 + const markerText = buildWorkflowMarker({ taskId: task.taskId, turnId: task.turnId || turnId, resourceKey: task.resourceKey, kind: 'final' }); + let restResult = null; + if (typeof options.sendRestReply === 'function') { + try { + restResult = await options.sendRestReply({ + task: clone(task), + body: `${task.finalText}${task.finalText ? '\n\n' : ''}${markerText}`, + marker: markerText, + source: 'rest_fallback', + }); + } catch (error) { + restResult = { status: 'unknown', error: error?.message || String(error || '') }; + } + } + const fallbackTurnId = task.turnId || turnId; + const fallbackVerification = await verifyReply(task, fallbackTurnId, 'rest_fallback'); + if (fallbackVerification.status === 'confirmed' || restResult?.confirmed === true) { + task.state = WORKFLOW_STATES.SUCCEEDED_WITH_REST_FALLBACK; + return emit(task, 'reply_confirmed', { turnId: fallbackTurnId, source: 'rest_fallback', commentId: fallbackVerification.commentId || fallbackVerification.comment?.id || restResult?.commentId || null }); + } + task.state = WORKFLOW_STATES.FAILED_REPLY; + return emit(task, 'reply_failed', { turnId: fallbackTurnId, source: 'rest_fallback', verification: fallbackVerification }); + } + + function queueWaitingUserComment(input = {}) { + const parent = tasks.get(requiredText(input.taskId, 'taskId', 256)); + const comment = normalizeComment(input.comment || {}); + if (isBotComment(input.comment || {}, botIdentity) || parseWorkflowMarker(comment.body)) { + return { ok: true, ignored: true, reason: 'bot_self_comment' }; + } + const taskId = requiredText(input.nextTaskId || input.comment?.taskId || `${parent.taskId}:continuation:${comment.id || Date.now()}`, 'nextTaskId', 256); + const next = createTask({ + taskId, + sessionKey: parent.sessionKey, + resourceKey: parent.resourceKey, + threadId: parent.threadId, + parentTaskId: parent.taskId, + metadata: { continuation: true, commentId: comment.id }, + }); + next.prompt = buildWaitingUserPrompt(comment); + const stored = tasks.get(taskId); + stored.prompt = next.prompt; + stored.waitingUser = null; + return emit(stored, 'waiting_user_comment_queued', { parentTaskId: parent.taskId, threadId: parent.threadId }); + } + + return { + createTask, + getTask, + listTasks, + threadConfig, + attachThread, + handleTurnStarted, + handleTurnEvent, + handleTurnCompleted, + queueWaitingUserComment, + buildWorkflowMarker, + parseWorkflowMarker, + isBotComment: (comment) => isBotComment(comment, botIdentity), + matchWorkflowReply: (comment, expected) => matchWorkflowReply(comment, expected, botIdentity), + states: WORKFLOW_STATES, + turnStates: TURN_STATES, + }; +} + +module.exports = { + DEFAULT_GITEA_MCP_COMMAND, + WORKFLOW_MARKER_NAME, + WORKFLOW_MARKER_VERSION, + WORKFLOW_STATES, + TURN_STATES, + buildGiteaMcpServerConfig, + buildGiteaMcpConfig, + buildGiteaThreadConfig, + mergeGiteaThreadConfig, + extractTurnId, + extractThreadId, + buildWorkflowMarker, + parseWorkflowMarker, + isBotComment, + normalizeComment, + matchWorkflowReply, + findWorkflowReply, + isWaitingUserEvent, + buildWaitingUserPrompt, + createGiteaWorkflowCodex, + encodeMarker, + buildWorkflowPrompt, + classifyReplyComments, +}; diff --git a/lib/gitea-workflow-domain.js b/lib/gitea-workflow-domain.js new file mode 100644 index 0000000..8ed6b6b --- /dev/null +++ b/lib/gitea-workflow-domain.js @@ -0,0 +1,318 @@ +'use strict'; + +/** + * Gitea Workflow 领域模型。 + * + * 该文件只负责无副作用的数据规范化、标识生成和状态机校验,便于 + * Webhook、调度器、Codex App 适配器以及管理 API 共享同一套契约。 + */ + +const crypto = require('crypto'); + +const TASK_STATES = Object.freeze({ + RECEIVED: 'received', + QUEUED: 'queued', + PREPARING: 'preparing', + RUNNING: 'running', + WAITING_USER: 'waiting_user', + VERIFYING_REPLY: 'verifying_reply', + RETRY_WAIT: 'retry_wait', + BLOCKED_WORKSPACE: 'blocked_workspace', + ABORTING: 'aborting', + SUCCEEDED: 'succeeded', + SUCCEEDED_WITH_REST_FALLBACK: 'succeeded_with_rest_fallback', + FAILED: 'failed', + FAILED_REPLY: 'failed_reply', + CANCELLED: 'cancelled', + ABORTED: 'aborted', + IGNORED: 'ignored', + DUPLICATE: 'duplicate', + REJECTED: 'rejected', +}); + +const TERMINAL_STATES = new Set([ + TASK_STATES.SUCCEEDED, + TASK_STATES.SUCCEEDED_WITH_REST_FALLBACK, + TASK_STATES.FAILED, + TASK_STATES.FAILED_REPLY, + TASK_STATES.CANCELLED, + TASK_STATES.ABORTED, + TASK_STATES.IGNORED, + TASK_STATES.DUPLICATE, + TASK_STATES.REJECTED, +]); + +const RUNNING_STATES = new Set([ + TASK_STATES.PREPARING, + TASK_STATES.RUNNING, + TASK_STATES.ABORTING, +]); + +const TASK_TRANSITIONS = Object.freeze({ + received: new Set(['queued', 'ignored', 'duplicate', 'rejected']), + queued: new Set(['preparing', 'cancelled']), + preparing: new Set(['running', 'blocked_workspace', 'retry_wait', 'failed']), + running: new Set(['waiting_user', 'verifying_reply', 'retry_wait', 'blocked_workspace', 'aborting', 'failed']), + waiting_user: new Set(['queued', 'cancelled', 'failed']), + verifying_reply: new Set(['succeeded', 'succeeded_with_rest_fallback', 'retry_wait', 'failed_reply']), + retry_wait: new Set(['preparing', 'running', 'verifying_reply', 'failed']), + blocked_workspace: new Set(['queued', 'cancelled']), + aborting: new Set(['aborted', 'failed']), +}); + +const TURN_STATES = Object.freeze({ + CREATED: 'created', + STARTING: 'starting', + RUNNING: 'running', + WAITING_USER: 'waiting_user', + COMPLETED: 'completed', + FAILED: 'failed', + ABORTING: 'aborting', + ABORTED: 'aborted', +}); + +function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function clone(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function text(value, max = 1000) { + return typeof value === 'string' ? value.trim().slice(0, max) : ''; +} + +function integer(value, fallback = 0) { + return Number.isSafeInteger(value) ? value : (Number.isSafeInteger(Number(value)) ? Number(value) : fallback); +} + +function iso(now = Date.now) { + const value = typeof now === 'function' ? now() : now; + const date = value instanceof Date ? value : new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString(); +} + +function id(prefix) { + return `${prefix}-${crypto.randomUUID()}`; +} + +function digest(value, length = 24) { + return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, length); +} + +function sessionKeyFor({ instanceId = 'default', owner, repo, kind, number } = {}) { + const normalizedKind = kind === 'pull_request' || kind === 'pr' ? 'pull_request' : 'issue'; + const ownerName = text(owner, 160); + const repoName = text(repo, 160); + const issueNumber = integer(number, 0); + if (!ownerName || !repoName || issueNumber <= 0) throw new TypeError('sessionKey 缺少 owner/repo/kind/number'); + return `${text(instanceId, 80) || 'default'}:${ownerName}/${repoName}:${normalizedKind}:${issueNumber}`; +} + +function resourceKeyFor(input = {}) { + // 资源身份必须包含编号,才能在回帖确认时区分同一仓库的不同 Issue/PR。 + return sessionKeyFor(input); +} + +function repoKeyFor({ instanceId = 'default', owner, repo, name } = {}) { + const repoName = text(repo || name, 160); + const ownerName = text(owner, 160); + if (!ownerName || !repoName) throw new TypeError('repoKey 缺少 owner/repo'); + return `${text(instanceId, 80) || 'default'}:${ownerName}/${repoName}`; +} + +function normalizeConfig(input = {}, env = process.env) { + const gitea = isObject(input.gitea) ? input.gitea : {}; + const codex = isObject(input.codex) ? input.codex : {}; + const workspaceRoot = text(input.workspaceRoot || env.CC_WEB_GITEA_WORKSPACE_ROOT, 1000) + || require('path').join(process.cwd(), 'gitea-workspaces'); + return Object.freeze({ + instanceId: text(input.instanceId || env.CC_WEB_GITEA_INSTANCE_ID, 80) || 'default', + botLogin: text(input.botLogin || env.CC_WEB_GITEA_BOT_LOGIN, 120) || 'ccweb-bot', + botId: input.botId ?? env.CC_WEB_GITEA_BOT_ID ?? null, + workspaceRoot, + gitea: Object.freeze({ + host: text(gitea.host || input.giteaHost || env.CC_WEB_GITEA_HOST, 500), + defaultBranchOverride: text(gitea.defaultBranchOverride || input.defaultBranchOverride || env.CC_WEB_GITEA_DEFAULT_BRANCH, 200) || '', + token: String(gitea.token ?? input.giteaToken ?? env.CC_WEB_GITEA_TOKEN ?? ''), + webhookSecret: String(gitea.webhookSecret ?? input.webhookSecret ?? env.CC_WEB_GITEA_WEBHOOK_SECRET ?? ''), + }), + codex: Object.freeze({ + command: text(codex.command || env.CC_WEB_CODEX_APP_COMMAND, 200) || 'codexapp', + mode: 'yolo', + model: text(codex.model || env.CC_WEB_CODEX_MODEL, 120) || null, + reasoningEffort: text(codex.reasoningEffort || env.CC_WEB_CODEX_REASONING_EFFORT, 80) || null, + developerInstructions: text(codex.developerInstructions, 4000) || null, + }), + }); +} + +function publicConfig(config) { + const value = clone(config || {}); + if (value.gitea) { + value.gitea.token = value.gitea.token ? `sha256:${digest(value.gitea.token, 16)}` : null; + value.gitea.webhookSecret = value.gitea.webhookSecret ? `sha256:${digest(value.gitea.webhookSecret, 16)}` : null; + } + return value; +} + +function createRepositoryRecord(input = {}, options = {}) { + const now = iso(options.now); + const owner = text(input.owner, 160); + const name = text(input.name || input.repo, 160); + if (!owner || !name) throw new TypeError('仓库记录缺少 owner/name'); + return { + key: input.key || repoKeyFor({ instanceId: input.instanceId, owner, repo: name }), + instanceId: text(input.instanceId, 80) || 'default', owner, name, + fullName: `${owner}/${name}`, + cloneUrl: text(input.cloneUrl || input.clone_url, 600) || null, + defaultBranch: text(input.defaultBranch || input.default_branch, 200) || null, + workspacePath: text(input.workspacePath, 1200) || null, + lastFetchAt: input.lastFetchAt || null, + status: text(input.status, 40) || 'active', + enabled: input.enabled !== false && input.status !== 'disabled', + dirty: input.dirty === true, + dirtyReason: text(input.dirtyReason, 1000) || null, + dirtySessionKey: text(input.dirtySessionKey, 300) || null, + createdAt: input.createdAt || now, + updatedAt: input.updatedAt || now, + version: Math.max(0, integer(input.version, 0)), + activeTaskId: text(input.activeTaskId, 160) || null, + }; +} + +function createSessionRecord(input = {}, options = {}) { + const now = iso(options.now); + const sessionKey = input.sessionKey || sessionKeyFor(input); + return { + sessionKey, + instanceId: text(input.instanceId, 80) || 'default', + owner: text(input.owner, 160), repo: text(input.repo || input.name, 160), + kind: input.kind === 'pull_request' || input.kind === 'pr' ? 'pull_request' : 'issue', + number: integer(input.number, 0), + resourceKey: input.resourceKey || resourceKeyFor(input), + threadId: text(input.threadId, 240) || null, + ccwebSessionId: text(input.ccwebSessionId, 240) || null, + status: text(input.status, 40) || 'idle', + createdAt: input.createdAt || now, updatedAt: input.updatedAt || now, + lastTurnId: text(input.lastTurnId, 240) || null, + version: Math.max(0, integer(input.version, 0)), + }; +} + +function createTaskRecord(input = {}, options = {}) { + const now = iso(options.now); + const taskId = text(input.taskId || input.id, 180) || id('gitea-task'); + const state = text(input.state || input.status, 40) || TASK_STATES.RECEIVED; + if (!Object.values(TASK_STATES).includes(state)) throw new TypeError(`未知任务状态: ${state}`); + return { + taskId, + deliveryKey: text(input.deliveryKey, 240) || null, + deliveryId: text(input.deliveryId, 180) || null, + repoKey: text(input.repoKey, 240) || null, + resourceKey: text(input.resourceKey, 300) || null, + sessionKey: text(input.sessionKey, 300) || null, + commentId: input.commentId ?? null, + actor: text(input.actor || input.author, 180) || null, + instruction: text(input.instruction || input.prompt, 8000), + state, + stateVersion: Math.max(0, integer(input.stateVersion, 0)), + attempt: Math.max(0, integer(input.attempt, 0)), + replyAttempt: Math.max(0, integer(input.replyAttempt, 0)), + threadId: text(input.threadId, 240) || null, + turnId: text(input.turnId, 240) || null, + turnState: text(input.turnState, 40) || null, + turnStartedAt: input.turnStartedAt || null, + turnUpdatedAt: input.turnUpdatedAt || null, + createdAt: input.createdAt || now, updatedAt: input.updatedAt || now, + nextRetryAt: input.nextRetryAt || null, + errorCode: text(input.errorCode, 160) || null, + errorMessage: text(input.errorMessage || input.error, 1200) || null, + metadata: isObject(input.metadata) ? clone(input.metadata) : {}, + }; +} + +function createTurnRecord(input = {}, options = {}) { + const now = iso(options.now); + return { + turnId: text(input.turnId, 240) || id('gitea-turn'), + taskId: text(input.taskId, 180) || null, + sessionKey: text(input.sessionKey, 300) || null, + threadId: text(input.threadId, 240) || null, + state: text(input.state, 40) || TURN_STATES.CREATED, + startedAt: input.startedAt || null, + finishedAt: input.finishedAt || null, + lastEventSeq: Math.max(0, integer(input.lastEventSeq, 0)), + retryCount: Math.max(0, integer(input.retryCount, 0)), + createdAt: input.createdAt || now, + updatedAt: input.updatedAt || now, + }; +} + +function canTransition(from, to) { + if (from === to) return true; + return Boolean(TASK_TRANSITIONS[from]?.has(to)); +} + +function transitionTask(task, nextState, options = {}) { + const current = task?.state || task?.status; + if (!canTransition(current, nextState)) { + const error = new Error(`任务状态不可从 ${current} 转为 ${nextState}`); + error.code = 'invalid_task_transition'; + error.fromState = current; + error.toState = nextState; + throw error; + } + const next = { ...clone(task), state: nextState, status: nextState, + stateVersion: integer(task.stateVersion, 0) + (current === nextState ? 0 : 1), + updatedAt: iso(options.now) }; + if (options.turnId !== undefined) next.turnId = options.turnId; + if (options.errorCode !== undefined) next.errorCode = options.errorCode; + if (options.errorMessage !== undefined) next.errorMessage = options.errorMessage; + if (options.nextRetryAt !== undefined) next.nextRetryAt = options.nextRetryAt; + return next; +} + +function recoverTasks(tasks, options = {}) { + const now = iso(options.now); + return (Array.isArray(tasks) ? tasks : []).map((task) => { + if (task.state === TASK_STATES.RUNNING || task.state === TASK_STATES.PREPARING) { + const retries = integer(task.attempt, 0); + if (retries < (options.maxRestartRetries ?? 1)) { + const recovered = transitionTask(task, TASK_STATES.RETRY_WAIT, { now: options.now, errorCode: 'interrupted_by_restart', errorMessage: '进程重启导致任务中断。', nextRetryAt: now }); + recovered.attempt = retries + 1; + return recovered; + } + return transitionTask(task, TASK_STATES.FAILED, { now: options.now, errorCode: 'restart_retry_exhausted', errorMessage: '重启恢复重试次数已耗尽。' }); + } + if (task.state === TASK_STATES.ABORTING) { + return transitionTask(task, TASK_STATES.FAILED, { now: options.now, errorCode: 'aborting_interrupted_by_restart', errorMessage: '中止确认期间进程重启。' }); + } + return clone(task); + }); +} + +module.exports = { + TASK_STATES, + TERMINAL_STATES, + RUNNING_STATES, + TASK_TRANSITIONS, + TURN_STATES, + canTransition, + clone, + createRepositoryRecord, + createSessionRecord, + createTaskRecord, + createTurnRecord, + digest, + iso, + normalizeConfig, + publicConfig, + recoverTasks, + repoKeyFor, + resourceKeyFor, + sessionKeyFor, + transitionTask, +}; diff --git a/lib/gitea-workflow-management.js b/lib/gitea-workflow-management.js new file mode 100644 index 0000000..fe28c96 --- /dev/null +++ b/lib/gitea-workflow-management.js @@ -0,0 +1,538 @@ +'use strict'; + +/** + * Gitea Workflow 管理面适配器。 + * + * 该模块不实现 Webhook、调度器或 Codex turn,只把核心 workflow service + * 暴露为稳定的查询/控制契约,并在核心服务尚未挂接时提供可恢复的只读状态。 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const TERMINAL_STATES = new Set([ + 'succeeded', 'succeeded_with_rest_fallback', 'failed', 'failed_reply', + 'cancelled', 'aborted', 'ignored', 'duplicate', 'rejected', +]); +const RUNNING_STATES = new Set(['preparing', 'running', 'aborting']); + +function nowIso() { + return new Date().toISOString(); +} + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function safeText(value, max = 2000) { + return String(value == null ? '' : value).slice(0, max); +} + +function requestId(value) { + const text = safeText(value, 120).trim(); + return text || crypto.randomUUID(); +} + +function stripGlobalConcurrency(settings = {}) { + const next = { ...settings }; + delete next.maxConcurrency; + return next; +} + +function createEmptyState() { + return { + version: 1, + control: { + globalPaused: false, + pauseReason: null, + updatedBy: 'system', + updatedAt: nowIso(), + version: 0, + }, + repositories: [], + tasks: [], + audits: [], + logs: [], + controlRequests: {}, + settings: { + host: '', botLogin: 'ccweb-bot', workspaceRoot: '', defaultBranch: '', + webhookSecretConfigured: false, botTokenConfigured: false, + updatedAt: nowIso(), updatedBy: 'system', + }, + updatedAt: nowIso(), + }; +} + +function readState(filePath) { + if (!filePath) return createEmptyState(); + try { + if (!fs.existsSync(filePath)) return createEmptyState(); + const stat = fs.statSync(filePath); + if (stat.size > 8 * 1024 * 1024) throw new Error('workflow 管理状态文件超过 8MB 上限'); + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return { + ...createEmptyState(), + ...parsed, + control: { ...createEmptyState().control, ...(parsed.control || {}) }, + repositories: Array.isArray(parsed.repositories) ? parsed.repositories : [], + tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [], + audits: Array.isArray(parsed.audits) ? parsed.audits : [], + logs: Array.isArray(parsed.logs) ? parsed.logs : [], + controlRequests: parsed.controlRequests && typeof parsed.controlRequests === 'object' ? parsed.controlRequests : {}, + settings: stripGlobalConcurrency({ ...createEmptyState().settings, ...(parsed.settings || {}) }), + }; + } catch { + return createEmptyState(); + } +} + +function writeState(filePath, state) { + if (!filePath) return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(tempPath, filePath); +} + +function normalizeRepository(repository = {}) { + const status = String(repository.status || (repository.enabled === false ? 'disabled' : 'active')); + return { + repoKey: safeText(repository.repoKey || repository.key || '', 240), + instanceId: safeText(repository.instanceId || 'default', 80), + owner: safeText(repository.owner || '', 120), + name: safeText(repository.name || repository.repo || '', 160), + cloneUrl: safeText(repository.cloneUrl || '', 500), + workspacePath: safeText(repository.workspacePath || repository.path || '', 1000), + defaultBranch: safeText(repository.defaultBranch || repository.default_branch || '', 160), + status, + enabled: status !== 'disabled', + registeredAt: repository.registeredAt || repository.createdAt || null, + lastFetchAt: repository.lastFetchAt || null, + dirty: !!(repository.dirty || repository.workspaceDirty || status === 'blocked'), + dirtyReason: safeText(repository.dirtyReason || repository.blockedReason || repository.workspaceReason || '', 1000) || null, + dirtySessionKey: safeText(repository.dirtySessionKey || '', 300) || null, + activeTaskId: safeText(repository.activeTaskId || '', 120) || null, + queuedCount: Number(repository.queuedCount || 0), + version: Number(repository.version || 0), + }; +} + +function normalizeTask(task = {}) { + const state = safeText(task.state || task.status || 'queued', 64) || 'queued'; + return { + taskId: safeText(task.taskId || task.id || '', 120), + repoKey: safeText(task.repoKey || '', 240), + resourceKey: safeText(task.resourceKey || '', 300), + sessionKey: safeText(task.sessionKey || '', 300), + commentId: task.commentId ?? null, + author: safeText(task.author || task.actor || '', 160), + instruction: safeText(task.instruction || task.prompt || '', 4000), + state, + stateLabel: safeText(task.stateLabel || '', 120) || null, + attempt: Number(task.attempt || 0), + replyAttempt: Number(task.replyAttempt || 0), + threadId: safeText(task.threadId || '', 240) || null, + turnId: safeText(task.turnId || '', 240) || null, + turnState: safeText(task.turnState || task.turn?.status || '', 80) || null, + turnStartedAt: task.turnStartedAt || task.turn?.startedAt || null, + turnUpdatedAt: task.turnUpdatedAt || task.turn?.updatedAt || null, + logs: Array.isArray(task.logs) ? task.logs.slice(-100).map((entry) => ({ + timestamp: entry.timestamp || entry.ts || null, + level: safeText(entry.level || 'info', 20), + message: safeText(entry.message || entry.event || '', 1000), + })) : [], + dirtyReason: safeText(task.dirtyReason || task.workspaceReason || (state === 'blocked_workspace' ? task.errorMessage || task.error : ''), 1000) || null, + errorCode: safeText(task.errorCode || '', 120) || null, + errorMessage: safeText(task.errorMessage || task.error || '', 1000) || null, + createdAt: task.createdAt || null, + updatedAt: task.updatedAt || null, + nextRetryAt: task.nextRetryAt || null, + terminal: TERMINAL_STATES.has(state), + }; +} + +function normalizeAudit(entry = {}) { + return { + eventId: safeText(entry.eventId || entry.id || '', 120), + taskId: safeText(entry.taskId || '', 120) || null, + repoKey: safeText(entry.repoKey || '', 240) || null, + action: safeText(entry.action || entry.event || '', 160), + actor: safeText(entry.actor || entry.operator || 'system', 160), + reason: safeText(entry.reason || '', 1000) || null, + fromState: safeText(entry.fromState || '', 64) || null, + toState: safeText(entry.toState || '', 64) || null, + deliveryId: safeText(entry.deliveryId || '', 160) || null, + turnId: safeText(entry.turnId || '', 240) || null, + commentId: entry.commentId ?? null, + errorCode: safeText(entry.errorCode || '', 120) || null, + timestamp: entry.timestamp || entry.createdAt || entry.ts || null, + metadata: entry.metadata && typeof entry.metadata === 'object' ? clone(entry.metadata) : {}, + }; +} + +function normalizeLog(entry = {}) { + return { + timestamp: entry.timestamp || entry.ts || null, + level: safeText(entry.level || 'info', 20), + taskId: safeText(entry.taskId || '', 120) || null, + repoKey: safeText(entry.repoKey || '', 240) || null, + event: safeText(entry.event || entry.action || '', 160), + message: safeText(entry.message || entry.error || '', 1600), + }; +} + +function pickService(service, names, ...args) { + if (!service) return undefined; + for (const name of names) { + if (typeof service[name] !== 'function') continue; + return service[name](...args); + } + return undefined; +} + +function collectionItems(value) { + if (Array.isArray(value)) return value; + if (Array.isArray(value?.items)) return value.items; + return []; +} + +function createGiteaWorkflowManagement(options = {}) { + const statePath = options.statePath || null; + let service = options.workflowService || null; + const clock = options.now || nowIso; + let state = readState(statePath); + const secretsPath = options.secretsPath || null; + const onConfigUpdate = typeof options.onConfigUpdate === 'function' ? options.onConfigUpdate : null; + + function readSecrets() { + if (!secretsPath) return {}; + try { return JSON.parse(fs.readFileSync(secretsPath, 'utf8')); } catch { return {}; } + } + function writeSecrets(next) { + if (!secretsPath) return; + fs.mkdirSync(path.dirname(secretsPath), { recursive: true }); + const temp = `${secretsPath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temp, JSON.stringify(next, null, 2), { mode: 0o600 }); + fs.renameSync(temp, secretsPath); + } + function configuration() { + const settings = stripGlobalConcurrency({ ...state.settings }); + const secrets = readSecrets(); + settings.webhookSecretConfigured = Boolean(secrets.webhookSecret || settings.webhookSecretConfigured); + settings.botTokenConfigured = Boolean(secrets.botToken || settings.botTokenConfigured); + return { settings, secrets: { webhookSecretConfigured: settings.webhookSecretConfigured, botTokenConfigured: settings.botTokenConfigured } }; + } + function runtimeConfiguration() { + const publicConfig = configuration(); + const secrets = readSecrets(); + return { settings: stripGlobalConcurrency(publicConfig.settings), secrets: { webhookSecret: secrets.webhookSecret || '', botToken: secrets.botToken || '' } }; + } + function updateConfiguration(input = {}) { + const actor = safeText(input.actor || 'admin', 160); + const reason = safeText(input.reason || '', 1000); + if (!reason) throw Object.assign(new Error('配置变更必须填写操作原因。'), { statusCode: 400, code: 'reason_required' }); + const current = runtimeConfiguration(); + const requestedWorkspace = safeText(input.workspaceRoot ?? state.settings.workspaceRoot, 1200); + const requestedHost = safeText(input.host ?? state.settings.host, 500); + if (requestedHost) { + let parsedHost; + try { parsedHost = new URL(requestedHost); } catch { + throw Object.assign(new Error('Gitea 地址必须是完整的 http/https URL。'), { statusCode: 400, code: 'invalid_gitea_host' }); + } + if (!['http:', 'https:'].includes(parsedHost.protocol) || parsedHost.username || parsedHost.password) { + throw Object.assign(new Error('Gitea 地址只允许 http/https,且不能携带凭据。'), { statusCode: 400, code: 'invalid_gitea_host' }); + } + } + if (requestedWorkspace && (!path.isAbsolute(requestedWorkspace) || path.resolve(requestedWorkspace) === path.parse(requestedWorkspace).root)) { + throw Object.assign(new Error('工作区根目录必须是非根的绝对路径。'), { statusCode: 400, code: 'invalid_workspace_root' }); + } + const nextSettings = { + ...state.settings, + host: requestedHost, + botLogin: safeText(input.botLogin ?? state.settings.botLogin, 120) || 'ccweb-bot', + workspaceRoot: requestedWorkspace, + defaultBranch: safeText(input.defaultBranch ?? state.settings.defaultBranch, 200), + updatedAt: clock(), updatedBy: actor, + }; + const secrets = { ...current.secrets }; + if (Object.prototype.hasOwnProperty.call(input, 'webhookSecret') && String(input.webhookSecret || '').trim()) secrets.webhookSecret = String(input.webhookSecret).trim(); + if (Object.prototype.hasOwnProperty.call(input, 'botToken') && String(input.botToken || '').trim()) secrets.botToken = String(input.botToken).trim(); + nextSettings.webhookSecretConfigured = Boolean(secrets.webhookSecret); + nextSettings.botTokenConfigured = Boolean(secrets.botToken); + state.settings = stripGlobalConcurrency(nextSettings); + writeSecrets(secrets); + appendAudit({ action: 'admin.configuration_updated', actor, reason, metadata: { changed: Object.keys(input).filter((key) => !['webhookSecret', 'botToken', 'maxConcurrency'].includes(key)) } }); + if (onConfigUpdate) onConfigUpdate({ settings: clone(state.settings), secrets: clone(secrets) }); + persist(); + return { settings: clone(state.settings), secrets: { webhookSecretConfigured: nextSettings.webhookSecretConfigured, botTokenConfigured: nextSettings.botTokenConfigured } }; + } + + function persist() { + state.updatedAt = clock(); + writeState(statePath, state); + } + + function appendAudit({ action, actor = 'system', reason = '', taskId = null, repoKey = null, fromState = null, toState = null, metadata = {} }) { + const item = normalizeAudit({ + eventId: crypto.randomUUID(), taskId, repoKey, action, actor, reason, + fromState, toState, metadata, timestamp: clock(), + }); + state.audits.push(item); + state.audits = state.audits.slice(-2000); + persist(); + return item; + } + + function sourceOverview() { + const value = pickService(service, ['getManagementOverview', 'getOverview', 'overview']); + return value && typeof value === 'object' ? value : null; + } + + function snapshot() { + const overview = sourceOverview() || {}; + const repositories = pickService(service, ['listRepositories', 'getRepositories']) || overview.repositories || state.repositories; + const tasks = pickService(service, ['listTasks', 'getTasks'], {}) || overview.tasks || state.tasks; + const auditSource = pickService(service, ['listAudit', 'listAudits', 'getAudit']); + const logSource = pickService(service, ['listLogs', 'getLogs']); + const audits = auditSource + ? [...state.audits, ...collectionItems(auditSource)] + : (overview.audits || state.audits); + const logs = logSource + ? [...state.logs, ...collectionItems(logSource)] + : (overview.logs || state.logs); + const normalizedTasks = collectionItems(tasks).map(normalizeTask); + const normalizedRepos = collectionItems(repositories).map(normalizeRepository); + const tasksByRepo = new Map(); + for (const task of normalizedTasks) { + if (!task.repoKey) continue; + const list = tasksByRepo.get(task.repoKey) || []; + list.push(task); + tasksByRepo.set(task.repoKey, list); + } + for (const repo of normalizedRepos) { + const repoTasks = tasksByRepo.get(repo.repoKey) || []; + repo.queuedCount = repoTasks.filter((task) => task.state === 'queued' || task.state === 'retry_wait').length; + repo.activeTaskId = repo.activeTaskId || repoTasks.find((task) => RUNNING_STATES.has(task.state))?.taskId || null; + const blockedTask = repoTasks.find((task) => task.state === 'blocked_workspace'); + if (blockedTask) { + repo.dirty = true; + repo.dirtyReason = repo.dirtyReason || blockedTask.dirtyReason || blockedTask.errorMessage || '工作区阻塞'; + repo.status = 'blocked'; + } + } + const runningCount = normalizedTasks.filter((task) => RUNNING_STATES.has(task.state)).length; + const queuedCount = normalizedTasks.filter((task) => task.state === 'queued' || task.state === 'retry_wait').length; + const control = { + ...state.control, + ...(overview.control || {}), + globalPaused: overview.globalPaused ?? overview.control?.globalPaused ?? state.control.globalPaused, + }; + const taskLogs = normalizedTasks.flatMap((task) => task.logs.map((entry) => ({ ...entry, taskId: task.taskId, repoKey: task.repoKey }))); + const normalizedLogs = (Array.isArray(logs) ? logs : []).map(normalizeLog); + const auditLogs = collectionItems(audits).map((entry) => ({ + timestamp: entry.timestamp || entry.createdAt || entry.ts || null, + level: entry.errorCode ? 'warn' : 'info', + taskId: entry.taskId || null, + repoKey: entry.repoKey || null, + event: entry.action || entry.event || 'workflow', + message: [entry.action || entry.event || 'workflow', entry.errorCode, entry.toState].filter(Boolean).join(' · '), + })).map(normalizeLog); + return { + ok: true, + available: !!service, + generatedAt: clock(), + control, + summary: { + running: Number(overview.summary?.running ?? runningCount), + queued: Number(overview.summary?.queued ?? queuedCount), + repositories: normalizedRepos.length, + blocked: normalizedRepos.filter((repo) => repo.dirty || repo.status === 'blocked').length, + }, + repositories: normalizedRepos, + tasks: normalizedTasks, + audits: Array.from(new Map(collectionItems(audits).map(normalizeAudit) + .map((entry) => [entry.eventId || `${entry.timestamp}:${entry.action}`, entry])).values()).slice(-200), + logs: [...normalizedLogs, ...taskLogs.map(normalizeLog), ...auditLogs].slice(-300), + }; + } + + function listTasks(query = {}) { + const all = snapshot().tasks; + const filtered = all.filter((task) => (!query.repoKey || task.repoKey === query.repoKey) + && (!query.resourceKey || task.resourceKey === query.resourceKey) + && (!query.state || task.state === query.state)); + const offset = Math.max(0, Number.parseInt(query.offset || 0, 10) || 0); + const limit = Math.min(200, Math.max(1, Number.parseInt(query.limit || 50, 10) || 50)); + return { items: filtered.slice(offset, offset + limit), total: filtered.length, offset, limit }; + } + + function resolveTask(taskId) { + const serviceTask = pickService(service, ['getTask', 'findTask'], taskId); + if (serviceTask) return normalizeTask(serviceTask); + return snapshot().tasks.find((task) => task.taskId === taskId) || null; + } + + function controlAction(action, input = {}) { + const actor = safeText(input.actor || 'admin', 160); + const reason = safeText(input.reason || '', 1000); + if (!reason) return { ok: false, statusCode: 400, code: 'reason_required', message: '控制操作必须填写原因。' }; + const rid = requestId(input.requestId); + if (state.controlRequests[rid]) { + return { ...clone(state.controlRequests[rid]), idempotent: true, overview: snapshot() }; + } + const previous = snapshot(); + const methodNames = { + pause: ['pause', 'pauseAll', 'setGlobalPaused'], + resume: ['resume', 'resumeAll', 'setGlobalPaused'], + disable: ['disableRepository', 'setRepositoryEnabled', 'setRepositoryStatus'], + enable: ['enableRepository', 'setRepositoryEnabled', 'setRepositoryStatus'], + cancel: ['cancelTask', 'cancelQueuedTask'], + abort: ['abortTask', 'abortTurn', 'cancelRunningTask'], + }[action] || []; + const serviceArgs = action === 'pause' || action === 'resume' + ? [{ actor, reason, requestId: rid }] + : [{ ...input, actor, reason, requestId: rid }]; + let result; + try { + result = pickService(service, methodNames, ...serviceArgs); + } catch (error) { + return { ok: false, statusCode: 503, code: 'workflow_service_error', message: safeText(error.message || error, 500) }; + } + if (!service) return { ok: false, statusCode: 503, code: 'workflow_service_unavailable', message: 'Workflow 核心服务尚未挂接,控制操作未执行。' }; + if (result === undefined) return { ok: false, statusCode: 503, code: 'workflow_control_unsupported', message: `核心服务未提供 ${action} 操作。` }; + if (result && result.ok === false) { + return { ...clone(result), ok: false, statusCode: result.code === 'task_not_found' || result.code === 'repository_not_found' ? 404 : 400 }; + } + const taskId = input.taskId || null; + const repoKey = input.repoKey || null; + appendAudit({ + action: `admin.${action}`, actor, reason, taskId, repoKey, + fromState: taskId ? resolveTask(taskId)?.state : null, + toState: taskId ? resolveTask(taskId)?.state : null, + metadata: { requestId: rid, result: result && typeof result === 'object' ? result : null }, + }); + const response = { ok: true, requestId: rid, action, result: result === true ? {} : clone(result), previous: previous.summary }; + state.controlRequests[rid] = response; + const requestIds = Object.keys(state.controlRequests); + while (requestIds.length > 500) delete state.controlRequests[requestIds.shift()]; + persist(); + return { ...clone(response), overview: snapshot() }; + } + + return Object.freeze({ + overview: snapshot, + listTasks, + getTask(taskId) { return resolveTask(taskId); }, + listAudit(query = {}) { + const items = snapshot().audits.filter((entry) => (!query.taskId || entry.taskId === query.taskId) + && (!query.repoKey || entry.repoKey === query.repoKey)); + return { items: items.slice(-Math.min(500, Number(query.limit || 200))), total: items.length }; + }, + control: controlAction, + configuration, + runtimeConfiguration, + updateConfiguration, + appendAudit, + setState(next) { + const cloned = clone(next) || {}; + const nextState = { ...state, ...cloned }; + if (cloned.settings) nextState.settings = stripGlobalConcurrency({ ...state.settings, ...cloned.settings }); + state = nextState; + persist(); + }, + setWorkflowService(nextService) { service = nextService || null; }, + getState() { return clone(state); }, + }); +} + +function readRequestBody(req, maxBytes = 256 * 1024) { + return new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + req.on('data', (chunk) => { + total += chunk.length; + if (total > maxBytes) { + reject(Object.assign(new Error('请求体过大'), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + try { + const text = Buffer.concat(chunks).toString('utf8').trim(); + resolve(text ? JSON.parse(text) : {}); + } catch (error) { + reject(Object.assign(new Error('请求体不是有效 JSON'), { statusCode: 400, cause: error })); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res, statusCode, payload) { + if (res.headersSent) return; + res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' }); + res.end(JSON.stringify(payload)); +} + +async function handleGiteaWorkflowManagementApi(req, res, url, management, options = {}) { + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + if (typeof options.authenticate === 'function' && !options.authenticate(req)) { + return sendJson(res, 401, { ok: false, code: 'unauthorized', message: 'Not authenticated' }); + } + if (!management) return sendJson(res, 503, { ok: false, code: 'workflow_service_unavailable', message: 'Workflow 管理服务不可用。' }); + try { + if (req.method === 'GET' && pathname === '/api/gitea-workflow/overview') return sendJson(res, 200, management.overview()); + if (req.method === 'GET' && pathname === '/api/gitea-workflow/config') return sendJson(res, 200, { ok: true, ...management.configuration() }); + if (req.method === 'PUT' && pathname === '/api/gitea-workflow/config') { + const body = await readRequestBody(req); + return sendJson(res, 200, { ok: true, ...management.updateConfiguration(body) }); + } + if (req.method === 'GET' && pathname === '/api/gitea-workflow/tasks') { + return sendJson(res, 200, { ok: true, ...management.listTasks(Object.fromEntries(url.searchParams.entries())) }); + } + const taskMatch = pathname.match(/^\/api\/gitea-workflow\/tasks\/([^/]+)$/); + if (req.method === 'GET' && taskMatch) { + const task = management.getTask(decodeURIComponent(taskMatch[1])); + return task ? sendJson(res, 200, { ok: true, task }) : sendJson(res, 404, { ok: false, code: 'task_not_found' }); + } + if (req.method === 'GET' && pathname === '/api/gitea-workflow/audit') { + return sendJson(res, 200, { ok: true, ...management.listAudit(Object.fromEntries(url.searchParams.entries())) }); + } + const controlMatch = pathname.match(/^\/api\/gitea-workflow\/control\/(pause|resume)$/); + if (req.method === 'POST' && controlMatch) { + const body = await readRequestBody(req); + const result = management.control(controlMatch[1], body); + return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result); + } + const repoMatch = pathname.match(/^\/api\/gitea-workflow\/repos\/(.+)\/(disable|enable)$/); + if (req.method === 'POST' && repoMatch) { + const body = await readRequestBody(req); + const result = management.control(repoMatch[2], { ...body, repoKey: decodeURIComponent(repoMatch[1]) }); + return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result); + } + const taskControlMatch = pathname.match(/^\/api\/gitea-workflow\/tasks\/([^/]+)\/(cancel|abort)$/); + if (req.method === 'POST' && taskControlMatch) { + const body = await readRequestBody(req); + const result = management.control(taskControlMatch[2], { ...body, taskId: decodeURIComponent(taskControlMatch[1]) }); + return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result); + } + return sendJson(res, 404, { ok: false, code: 'not_found' }); + } catch (error) { + return sendJson(res, error.statusCode || 500, { + ok: false, + code: error.code || 'workflow_api_error', + message: safeText(error.message || error, 500), + }); + } +} + +module.exports = { + createGiteaWorkflowManagement, + handleGiteaWorkflowManagementApi, + normalizeRepository, + normalizeTask, + normalizeAudit, +}; diff --git a/lib/gitea-workflow-queue.js b/lib/gitea-workflow-queue.js new file mode 100644 index 0000000..688746e --- /dev/null +++ b/lib/gitea-workflow-queue.js @@ -0,0 +1,238 @@ +'use strict'; + +/** + * Gitea Workflow 调度器:同仓库串行、跨仓库并行。 + * + * 调度器只编排领域任务,不负责 clone、Codex 或 Gitea REST;这些通过 + * `runner(task, context)` 注入,runner 返回 `{ state, ...patch }` 即可更新任务。 + */ + +const { EventEmitter } = require('events'); +const domain = require('./gitea-workflow-domain'); + +function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function taskOrder(task) { + const commentCreatedAt = task?.metadata?.event?.comment?.createdAt + || task?.metadata?.event?.comment?.created_at + || task?.metadata?.event?.receivedAt; + const timestamp = Date.parse(commentCreatedAt || task?.createdAt || '') || Number.MAX_SAFE_INTEGER; + return `${String(timestamp).padStart(16, '0')}:${String(task?.deliveryId || task?.taskId || '')}`; +} + +class GiteaWorkflowQueue extends EventEmitter { + constructor(options = {}) { + super(); + if (!options.store) throw new TypeError('GiteaWorkflowQueue 需要注入 store'); + this.store = options.store; + this.clock = typeof options.now === 'function' ? options.now : Date.now; + this.defaultRunner = typeof options.runner === 'function' ? options.runner : async () => ({ state: domain.TASK_STATES.SUCCEEDED }); + this.pending = []; + this.active = new Map(); + this.repoActive = new Map(); + this.runners = new Map(); + this.paused = Boolean(options.paused || this.store.getControl?.().paused); + this.draining = false; + this.started = false; + if (options.autoRecover !== false) this.recover(); + } + + recover() { + const tasks = this.store.recover({ maxRestartRetries: 1, now: this.clock }); + this.pending = tasks.filter((task) => task.state === domain.TASK_STATES.QUEUED + || task.state === domain.TASK_STATES.RETRY_WAIT) + .sort((a, b) => taskOrder(a).localeCompare(taskOrder(b))) + .map((task) => task.taskId); + this.started = true; + this.drain(); + return tasks; + } + + setRunner(taskId, runner) { + if (typeof runner === 'function') this.runners.set(String(taskId), runner); + return this; + } + + enqueue(taskOrId, runner) { + const taskId = typeof taskOrId === 'string' ? taskOrId : taskOrId?.taskId; + if (!taskId) return Promise.reject(new TypeError('enqueue 缺少 taskId')); + let task = this.store.getTask(taskId); + if (!task && typeof taskOrId === 'object') task = this.store.createTask(taskOrId); + if (!task) return Promise.reject(Object.assign(new Error('找不到任务'), { code: 'task_not_found' })); + if (domain.TERMINAL_STATES.has(task.state)) return Promise.resolve({ task: this.store.getTask(taskId), skipped: true }); + if (task.state !== domain.TASK_STATES.QUEUED) { + task = this.store.transitionTask(taskId, domain.TASK_STATES.QUEUED); + } + if (!this.pending.includes(taskId) && !this.active.has(taskId)) this.pending.push(taskId); + this.pending.sort((leftId, rightId) => taskOrder(this.store.getTask(leftId)).localeCompare(taskOrder(this.store.getTask(rightId)))); + if (typeof runner === 'function') this.runners.set(taskId, runner); + this.emit('queued', this.store.getTask(taskId)); + this.drain(); + return this.waitForTerminal(taskId); + } + + waitForTerminal(taskId, timeoutMs = 0) { + const existing = this.store.getTask(taskId); + if (existing && (domain.TERMINAL_STATES.has(existing.state) || existing.state === domain.TASK_STATES.WAITING_USER)) { + return Promise.resolve({ task: existing }); + } + return new Promise((resolve, reject) => { + let timer = null; + const check = (task) => { + if (!task || task.taskId !== taskId) return; + if (domain.TERMINAL_STATES.has(task.state) || task.state === domain.TASK_STATES.WAITING_USER) { + cleanup(); resolve({ task }); + } + }; + const cleanup = () => { + this.off('updated', check); + if (timer) clearTimeout(timer); + }; + this.on('updated', check); + if (timeoutMs > 0) timer = setTimeout(() => { cleanup(); reject(new Error('等待任务完成超时')); }, timeoutMs); + check(this.store.getTask(taskId)); + }); + } + + pause(input = {}) { + this.paused = true; + this.store.setPaused(true, input); + this.emit('paused', this.store.getControl()); + return this.store.getControl(); + } + + resume(input = {}) { + this.paused = false; + this.store.setPaused(false, input); + this.emit('resumed', this.store.getControl()); + this.drain(); + return this.store.getControl(); + } + + cancelQueued(taskId, input = {}) { + const task = this.store.getTask(taskId); + if (!task) return { ok: false, code: 'task_not_found' }; + if (task.state !== domain.TASK_STATES.QUEUED && task.state !== domain.TASK_STATES.RETRY_WAIT + && task.state !== domain.TASK_STATES.BLOCKED_WORKSPACE && task.state !== domain.TASK_STATES.WAITING_USER) { + return { ok: false, code: 'task_not_queued', task }; + } + this.pending = this.pending.filter((id) => id !== String(taskId)); + const updated = this.store.transitionTask(taskId, domain.TASK_STATES.CANCELLED, { errorCode: input.reason || 'cancelled_by_operator' }); + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, actor: input.actor || 'admin', action: 'task.cancelled', fromState: task.state, toState: updated.state, metadata: { reason: input.reason || null } }); + this.emit('updated', updated); + return { ok: true, task: updated }; + } + + abortRunning(taskId, input = {}) { + const entry = this.active.get(String(taskId)); + const task = this.store.getTask(taskId); + if (!task) return { ok: false, code: 'task_not_found' }; + if (!entry || !domain.RUNNING_STATES.has(task.state)) { + if (task.state === domain.TASK_STATES.ABORTED || task.state === domain.TASK_STATES.CANCELLED) return { ok: true, task }; + return { ok: false, code: 'task_not_running', task }; + } + const updated = this.store.transitionTask(taskId, domain.TASK_STATES.ABORTING, { errorCode: input.reason || 'abort_requested' }); + entry.abortRequested = true; + try { entry.controller.abort(new Error(input.reason || '管理员请求中止')); } catch {} + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, actor: input.actor || 'admin', action: 'task.abort_requested', fromState: task.state, toState: updated.state, turnId: task.turnId, metadata: { reason: input.reason || null } }); + this.emit('updated', updated); + return { ok: true, task: updated }; + } + + getStatus() { + return { + paused: this.paused, + running: this.active.size, + queued: this.pending.length, + activeTaskIds: [...this.active.keys()], + }; + } + + async drain() { + if (this.draining || this.paused) return; + this.draining = true; + try { + while (!this.paused) { + const index = this.pending.findIndex((id) => { + const task = this.store.getTask(id); + return task && (task.state === domain.TASK_STATES.QUEUED || task.state === domain.TASK_STATES.RETRY_WAIT) + && !this.repoActive.has(task.repoKey || task.taskId) + && (!task.nextRetryAt || new Date(task.nextRetryAt).getTime() <= this.clock()); + }); + if (index < 0) break; + const taskId = this.pending.splice(index, 1)[0]; + this.run(taskId).catch(() => undefined); + } + } finally { + this.draining = false; + } + } + + async run(taskId) { + const current = this.store.getTask(taskId); + if (!current) return; + const repoLockKey = current.repoKey || current.taskId; + this.repoActive.set(repoLockKey, taskId); + const controller = new AbortController(); + this.active.set(taskId, { controller, abortRequested: false, startedAt: domain.iso(this.clock) }); + let task = this.store.transitionTask(taskId, domain.TASK_STATES.PREPARING); + task = this.store.upsertTask({ ...task, attempt: Number(task.attempt || 0) + 1 }); + this.emit('updated', task); + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, action: 'task.preparing', fromState: current.state, toState: task.state, deliveryId: task.deliveryId }); + try { + task = this.store.transitionTask(taskId, domain.TASK_STATES.RUNNING); + this.emit('updated', task); + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, action: 'task.running', fromState: domain.TASK_STATES.PREPARING, toState: task.state, turnId: task.turnId }); + const runner = this.runners.get(taskId) || this.defaultRunner; + const result = await runner(this.store.getTask(taskId), { signal: controller.signal, queue: this, status: this.getStatus() }); + const patch = result && typeof result === 'object' ? result : {}; + let desired = patch.state || (controller.signal.aborted ? domain.TASK_STATES.ABORTED : domain.TASK_STATES.SUCCEEDED); + if (patch.waitingUser) desired = domain.TASK_STATES.WAITING_USER; + if (patch.replyConfirmed) { + desired = patch.usedRestFallback + ? domain.TASK_STATES.SUCCEEDED_WITH_REST_FALLBACK + : domain.TASK_STATES.SUCCEEDED; + } + if (desired === domain.TASK_STATES.ABORTED && task.state !== domain.TASK_STATES.ABORTING) { + task = this.store.transitionTask(taskId, domain.TASK_STATES.ABORTING); + } + if (desired === domain.TASK_STATES.WAITING_USER) { + task = this.store.transitionTask(taskId, desired, patch); + } else if (domain.canTransition(task.state, desired)) { + task = this.store.transitionTask(taskId, desired, patch); + } else if (task.state === domain.TASK_STATES.RUNNING + && (desired === domain.TASK_STATES.SUCCEEDED || desired === domain.TASK_STATES.SUCCEEDED_WITH_REST_FALLBACK)) { + task = this.store.transitionTask(taskId, domain.TASK_STATES.VERIFYING_REPLY, patch); + task = this.store.transitionTask(taskId, desired, patch); + } else if (task.state === domain.TASK_STATES.RUNNING && desired === domain.TASK_STATES.FAILED_REPLY) { + task = this.store.transitionTask(taskId, domain.TASK_STATES.VERIFYING_REPLY, patch); + task = this.store.transitionTask(taskId, desired, patch); + } else { + task = this.store.transitionTask(taskId, controller.signal.aborted ? domain.TASK_STATES.ABORTED : domain.TASK_STATES.FAILED, { errorCode: 'invalid_runner_state' }); + } + if (patch.turn) this.store.upsertTurn(patch.turn); + this.emit('updated', task); + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, action: `task.${task.state}`, fromState: domain.TASK_STATES.RUNNING, toState: task.state, turnId: task.turnId, errorCode: task.errorCode }); + if (task.state === domain.TASK_STATES.RETRY_WAIT) { + this.pending.push(task.taskId); + } + } catch (error) { + const latest = this.store.getTask(taskId); + const target = latest.state === domain.TASK_STATES.ABORTING + ? domain.TASK_STATES.ABORTED + : (error.code === 'blocked_workspace' ? domain.TASK_STATES.BLOCKED_WORKSPACE : domain.TASK_STATES.FAILED); + task = this.store.transitionTask(taskId, target, { errorCode: error.code || 'runner_failed', errorMessage: error.message || String(error) }); + this.emit('updated', task); + this.store.appendAudit({ taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, action: `task.${target}`, fromState: latest.state, toState: target, errorCode: task.errorCode }); + } finally { + this.active.delete(taskId); + this.repoActive.delete(repoLockKey); + this.runners.delete(taskId); + this.drain(); + } + } +} + +function createGiteaWorkflowQueue(options) { return new GiteaWorkflowQueue(options); } + +module.exports = { GiteaWorkflowQueue, createGiteaWorkflowQueue, sleep }; diff --git a/lib/gitea-workflow-service.js b/lib/gitea-workflow-service.js new file mode 100644 index 0000000..a90fb70 --- /dev/null +++ b/lib/gitea-workflow-service.js @@ -0,0 +1,314 @@ +'use strict'; + +/** + * Gitea Workflow 核心编排服务。 + * + * 该服务是 Webhook/管理 API 与领域存储、队列之间的适配层。server.js + * 只需在路由中注入本服务实例,即可逐步接入,不需要复制状态机逻辑。 + */ + +const crypto = require('crypto'); +const domain = require('./gitea-workflow-domain'); +const { createGiteaWorkflowStore } = require('./gitea-workflow-store'); +const { createGiteaWorkflowQueue } = require('./gitea-workflow-queue'); +const { + normalizeWebhookEvent, + verifyWebhookSignature, +} = require('./gitea-webhook'); + +function safeText(value, max = 1000) { + return typeof value === 'string' ? value.trim().slice(0, max) : ''; +} + +function header(headers, names) { + const source = headers || {}; + for (const wanted of names) { + for (const [key, value] of Object.entries(source)) { + if (key.toLowerCase() === wanted.toLowerCase()) return Array.isArray(value) ? value[0] : value; + } + } + return ''; +} + +function buildGiteaMcpConfig(config, options = {}) { + const normalized = domain.normalizeConfig(config || {}); + const host = safeText(options.host || normalized.gitea.host, 500); + const token = String(options.token ?? normalized.gitea.token ?? ''); + const command = safeText(options.command || normalized.gitea.mcpCommand, 200) || 'gitea-mcp'; + const args = Array.isArray(options.args) ? options.args.slice() : ['-t', 'stdio']; + if (host && !args.includes('-H')) args.push('-H', host); + return { + type: 'stdio', + command, + args, + env: { + ...(options.env || {}), + GITEA_HOST: host, + GITEA_ACCESS_TOKEN: token, + GITEA_TOKEN: token, + }, + }; +} + +function parseBody(rawBody) { + try { + return JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody || '')); + } catch { + const error = new Error('Webhook 请求体不是有效 JSON'); + error.code = 'invalid_json'; + throw error; + } +} + +class GiteaWorkflowService { + constructor(options = {}) { + this.config = domain.normalizeConfig(options.config || options, options.env || process.env); + this.store = options.store || createGiteaWorkflowStore({ + filePath: options.statePath || options.filePath, + now: options.now, + }); + this.clock = typeof options.now === 'function' ? options.now : Date.now; + this.runner = typeof options.runner === 'function' ? options.runner : null; + this.queue = options.queue || createGiteaWorkflowQueue({ + store: this.store, + runner: this.runner || (async (task) => ({ state: domain.TASK_STATES.FAILED, errorCode: 'runner_not_configured', errorMessage: `未配置 Codex runner(${task.taskId})。` })), + autoRecover: options.autoRecover !== false, + now: this.clock, + }); + this.queue.on('updated', (task) => this.emitTaskAudit(task)); + } + + emitTaskAudit(task) { + if (!task || !task.taskId) return null; + return this.store.appendAudit({ + taskId: task.taskId, + sessionKey: task.sessionKey, + repoKey: task.repoKey, + action: 'task.updated', + toState: task.state, + deliveryId: task.deliveryId, + turnId: task.turnId, + errorCode: task.errorCode, + }); + } + + verify(rawBody, headers = {}) { + const secret = this.config.gitea.webhookSecret; + // 内部部署可以不配置 Secret;配置后仍严格执行 HMAC 验签。 + if (!secret) return true; + const signature = header(headers, ['x-gitea-signature', 'x-hub-signature-256', 'x-signature']); + return Boolean(secret && verifyWebhookSignature(rawBody, signature, secret)); + } + + async ingestWebhook({ rawBody, headers = {} } = {}) { + if (!this.verify(rawBody, headers)) return { statusCode: 401, payload: { ok: false, code: 'invalid_signature' } }; + let payload; + try { payload = parseBody(rawBody); } catch (error) { return { statusCode: 400, payload: { ok: false, code: error.code } }; } + const deliveryId = safeText(header(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || payload.delivery_id, 180); + if (!deliveryId) return { statusCode: 400, payload: { ok: false, code: 'missing_delivery_id' } }; + const deliveryKey = `${this.config.instanceId}:${deliveryId}`; + const claim = this.store.claimDelivery(deliveryKey, { deliveryId, metadata: { event: header(headers, ['x-gitea-event']) } }); + if (claim.duplicate) { + return { statusCode: 200, payload: { ok: true, duplicate: true, deliveryId, taskId: claim.record.taskId }, record: claim.record }; + } + const event = normalizeWebhookEvent({ + payload, + headers, + instanceId: this.config.instanceId, + botIdentity: { login: this.config.botLogin, id: this.config.botId }, + }); + if (event.ignored) { + this.store.updateDelivery(deliveryKey, { status: 'ignored', reason: event.reason }); + this.store.appendAudit({ action: 'webhook.ignored', actor: 'gitea', deliveryId, metadata: { reason: event.reason, eventName: event.eventName } }); + return { statusCode: 200, payload: { ok: true, ignored: true, reason: event.reason, deliveryId }, event }; + } + const repository = this.store.upsertRepository({ + instanceId: this.config.instanceId, + owner: event.repository.owner, + name: event.repository.name, + cloneUrl: event.repository.cloneUrl, + defaultBranch: this.config.gitea.defaultBranchOverride || event.repository.defaultBranch, + }); + if (repository.enabled === false || repository.status === 'disabled') { + this.store.updateDelivery(deliveryKey, { status: 'rejected', reason: 'repository_disabled' }); + this.store.appendAudit({ action: 'webhook.rejected', actor: 'gitea', deliveryId, repoKey: repository.key, errorCode: 'repository_disabled' }); + return { statusCode: 200, payload: { ok: true, rejected: true, reason: 'repository_disabled', deliveryId } }; + } + const sessionInput = { + instanceId: this.config.instanceId, + owner: event.repository.owner, + repo: event.repository.name, + kind: event.resource.kind, + number: event.resource.number, + }; + const session = this.store.upsertSession(sessionInput); + const task = this.store.createTask({ + taskId: `gitea-task-${crypto.createHash('sha256').update(deliveryKey).digest('hex').slice(0, 24)}`, + deliveryKey, deliveryId, repoKey: repository.key, resourceKey: session.resourceKey, + sessionKey: session.sessionKey, commentId: event.comment.id || null, + actor: event.comment.author.login || null, instruction: event.mention.instruction, + state: domain.TASK_STATES.RECEIVED, + // 保留经过规范化的仓库/资源上下文,供工作区与 Codex runner 使用;其中不含 Token/Secret。 + metadata: { eventName: event.eventName, resource: event.resource, event }, + }); + this.store.updateDelivery(deliveryKey, { status: 'accepted', taskId: task.taskId }); + this.store.appendAudit({ action: 'webhook.accepted', actor: 'gitea', deliveryId, taskId: task.taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, fromState: domain.TASK_STATES.RECEIVED, toState: domain.TASK_STATES.QUEUED, commentId: task.commentId }); + if (this.runner) this.queue.setRunner(task.taskId, this.runner); + this.queue.enqueue(task.taskId).catch(() => undefined); + return { statusCode: 202, payload: { ok: true, accepted: true, deliveryId, taskId: task.taskId, sessionKey: task.sessionKey }, task, event }; + } + + /** + * 接收已经由独立 Webhook receiver 验签和规范化的任务,供 server.js + * 保持最小挂接点。该方法仍在本地 store 中执行 delivery 幂等。 + */ + enqueueNormalizedTask(input = {}) { + const event = input.event || input; + const deliveryId = safeText(input.deliveryId || event.deliveryId, 180); + const deliveryKey = safeText(input.deliveryKey || `${this.config.instanceId}:${deliveryId}`, 240); + const claim = this.store.claimDelivery(deliveryKey, { deliveryId, taskId: input.taskId || null, metadata: { source: 'normalized_receiver' } }); + if (claim.duplicate) { + return { ok: true, duplicate: true, task: claim.record.taskId ? this.store.getTask(claim.record.taskId) : null, delivery: claim.record }; + } + const repository = this.store.upsertRepository({ + instanceId: this.config.instanceId, + owner: event.repository?.owner, + name: event.repository?.name, + cloneUrl: event.repository?.cloneUrl, + defaultBranch: this.config.gitea.defaultBranchOverride || event.repository?.defaultBranch, + }); + if (repository.enabled === false || repository.status === 'disabled') { + this.store.updateDelivery(deliveryKey, { status: 'rejected', reason: 'repository_disabled' }); + this.store.appendAudit({ action: 'webhook.rejected', actor: 'gitea', deliveryId, repoKey: repository.key, errorCode: 'repository_disabled' }); + return { ok: false, duplicate: false, rejected: true, code: 'repository_disabled', repository }; + } + const resource = event.resource || {}; + const session = this.store.upsertSession({ instanceId: this.config.instanceId, owner: repository.owner, repo: repository.name, kind: resource.kind, number: resource.number }); + const task = this.store.createTask({ + taskId: input.taskId || `gitea-task-${crypto.createHash('sha256').update(deliveryKey).digest('hex').slice(0, 24)}`, + deliveryKey, deliveryId, repoKey: repository.key, resourceKey: session.resourceKey, sessionKey: session.sessionKey, + commentId: event.comment?.id || null, actor: event.comment?.author?.login || null, + instruction: event.mention?.instruction || event.comment?.body || '', metadata: { eventName: event.eventName, resource, event }, + state: domain.TASK_STATES.RECEIVED, + }); + this.store.updateDelivery(deliveryKey, { status: 'accepted', taskId: task.taskId }); + this.store.appendAudit({ action: 'webhook.accepted', actor: 'gitea', deliveryId, taskId: task.taskId, repoKey: task.repoKey, sessionKey: task.sessionKey, commentId: task.commentId }); + if (this.runner) this.queue.setRunner(task.taskId, this.runner); + this.queue.enqueue(task.taskId).catch(() => undefined); + return { ok: true, duplicate: false, task, repository, session }; + } + + /** 运行时挂接 Codex/工作区 runner,并立即尝试领取队列。 */ + setRunner(runner) { + if (typeof runner !== 'function') throw new TypeError('runner 必须是函数'); + this.runner = runner; + this.queue.defaultRunner = runner; + for (const task of this.store.listTasks({ state: domain.TASK_STATES.QUEUED })) this.queue.setRunner(task.taskId, runner); + this.queue.drain(); + return this; + } + + /** + * 在进程内更新管理页保存的连接配置,无需重启服务。 + * Secret 只从管理适配器传入内存配置,不会写入任务、日志或公开响应。 + */ + updateConfig(input = {}) { + const current = this.config || {}; + const currentGitea = current.gitea || {}; + this.config = domain.normalizeConfig({ + ...current, + instanceId: input.instanceId ?? current.instanceId, + botLogin: input.botLogin ?? current.botLogin, + botId: input.botId ?? current.botId, + workspaceRoot: input.workspaceRoot ?? current.workspaceRoot, + defaultBranchOverride: input.defaultBranchOverride ?? currentGitea.defaultBranchOverride, + gitea: { + ...currentGitea, + ...(input.gitea || {}), + host: input.host ?? input.gitea?.host ?? currentGitea.host, + token: input.botToken ?? input.gitea?.token ?? currentGitea.token, + webhookSecret: input.webhookSecret ?? input.gitea?.webhookSecret ?? currentGitea.webhookSecret, + defaultBranchOverride: input.defaultBranchOverride ?? input.gitea?.defaultBranchOverride ?? currentGitea.defaultBranchOverride, + }, + }); + return this.config; + } + + getOverview() { + const tasks = this.store.listTasks(); + const repositories = this.store.listRepositories(); + return { + ok: true, + available: true, + generatedAt: domain.iso(this.clock), + control: this.store.getControl(), + summary: { + running: tasks.filter((task) => domain.RUNNING_STATES.has(task.state)).length, + queued: tasks.filter((task) => task.state === domain.TASK_STATES.QUEUED || task.state === domain.TASK_STATES.RETRY_WAIT).length, + repositories: repositories.length, + blocked: repositories.filter((repo) => repo.dirty || repo.status === 'blocked').length, + }, + repositories, + tasks, + audits: this.store.listAudits().slice(-200), + queue: this.queue.getStatus(), + }; + } + + listRepositories(query = {}) { return this.store.listRepositories(query); } + listTasks(query = {}) { return this.store.listTasks(query); } + getTask(taskId) { return this.store.getTask(taskId); } + listSessions(query = {}) { return this.store.listSessions(query); } + listTurns(query = {}) { return this.store.listTurns(query); } + listAudits(query = {}) { return this.store.listAudits(query); } + listAudit(query = {}) { return this.listAudits(query); } + + pause(input = {}) { const result = this.queue.pause(input); this.store.appendAudit({ action: 'admin.pause', actor: input.actor || 'admin', metadata: { reason: input.reason || null } }); return result; } + resume(input = {}) { const result = this.queue.resume(input); this.store.appendAudit({ action: 'admin.resume', actor: input.actor || 'admin', metadata: { reason: input.reason || null } }); return result; } + + setRepositoryEnabled(repoKey, enabled, input = {}) { + const repository = this.store.getRepository(repoKey); + if (!repository) return { ok: false, code: 'repository_not_found' }; + const updated = this.store.upsertRepository({ ...repository, enabled: Boolean(enabled), status: enabled ? 'active' : 'disabled' }); + this.store.appendAudit({ action: enabled ? 'admin.repository_enabled' : 'admin.repository_disabled', actor: input.actor || 'admin', repoKey, metadata: { reason: input.reason || null } }); + if (enabled) { + for (const task of this.store.listTasks({ repoKey, state: domain.TASK_STATES.BLOCKED_WORKSPACE })) { + try { this.queue.enqueue(task.taskId); } catch {} + } + } + return { ok: true, repository: updated }; + } + disableRepository(input = {}) { return this.setRepositoryEnabled(input.repoKey, false, input); } + enableRepository(input = {}) { return this.setRepositoryEnabled(input.repoKey, true, input); } + cancelTask(input = {}) { return this.queue.cancelQueued(input.taskId || input.id, input); } + abortTask(input = {}) { return this.queue.abortRunning(input.taskId || input.id, input); } + abortTurn(input = {}) { return this.abortTask(input); } + buildThreadConfig(options = {}) { + const session = options.session || {}; + const settings = { + mode: 'yolo', + ...(this.config.codex.model ? { model: this.config.codex.model } : {}), + ...(this.config.codex.reasoningEffort ? { reasoning_effort: this.config.codex.reasoningEffort } : {}), + ...(this.config.codex.developerInstructions ? { developer_instructions: this.config.codex.developerInstructions } : {}), + }; + return { + cwd: options.cwd || null, + mcp_servers: { + gitea: buildGiteaMcpConfig(this.config, options), + }, + collaborationMode: { settings }, + sessionKey: session.sessionKey || null, + }; + } + + publicConfig() { return domain.publicConfig(this.config); } +} + +function createGiteaWorkflowService(options) { return new GiteaWorkflowService(options); } + +module.exports = { + GiteaWorkflowService, + buildGiteaMcpConfig, + createGiteaWorkflowService, + parseBody, +}; diff --git a/lib/gitea-workflow-store.js b/lib/gitea-workflow-store.js new file mode 100644 index 0000000..f016c84 --- /dev/null +++ b/lib/gitea-workflow-store.js @@ -0,0 +1,300 @@ +'use strict'; + +/** + * Gitea Workflow 持久化仓库。 + * + * 使用单个 JSON 文件保存 MVP 领域状态,所有写入都通过同目录临时文件 + * + rename 完成,进程崩溃时不会留下半截 JSON。业务层可以替换为数据库, + * 但应保持本文件暴露的幂等键和查询契约。 + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const domain = require('./gitea-workflow-domain'); + +const MAX_AUDITS = 10_000; +const MAX_DELIVERIES = 20_000; + +function clone(value) { + return domain.clone(value); +} + +function atomicWriteJson(filePath, value) { + const target = path.resolve(filePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`; + fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + try { + fs.renameSync(temp, target); + } finally { + try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch {} + } +} + +function emptyState() { + return { + schemaVersion: 1, + updatedAt: domain.iso(), + control: { paused: false, reason: null, actor: 'system', version: 0, updatedAt: domain.iso() }, + repositories: {}, + sessions: {}, + tasks: {}, + turns: {}, + deliveries: {}, + audits: [], + }; +} + +function sanitizeMetadata(value, depth = 0) { + if (depth > 4) return '[truncated]'; + if (Array.isArray(value)) return value.slice(0, 100).map((item) => sanitizeMetadata(item, depth + 1)); + if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 2000) : value; + const output = {}; + for (const [key, item] of Object.entries(value)) { + if (/token|secret|password|authorization|api[-_]?key/i.test(key)) { + output[key] = '[redacted]'; + } else { + output[key] = sanitizeMetadata(item, depth + 1); + } + } + return output; +} + +class GiteaWorkflowStore { + constructor(options = {}) { + this.filePath = options.filePath ? path.resolve(options.filePath) : null; + this.clock = typeof options.now === 'function' ? options.now : Date.now; + this.state = emptyState(); + this.load(); + } + + load() { + if (!this.filePath || !fs.existsSync(this.filePath)) return this.state; + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const fresh = emptyState(); + this.state = { + ...fresh, + ...parsed, + control: { ...fresh.control, ...(parsed.control || {}) }, + repositories: parsed.repositories && typeof parsed.repositories === 'object' ? parsed.repositories : {}, + sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {}, + tasks: parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {}, + turns: parsed.turns && typeof parsed.turns === 'object' ? parsed.turns : {}, + deliveries: parsed.deliveries && typeof parsed.deliveries === 'object' ? parsed.deliveries : {}, + audits: Array.isArray(parsed.audits) ? parsed.audits : [], + }; + } + } catch { + // 启动时损坏的状态文件不能让 Webhook 进程直接退出;保留空状态并在下次写入时修复。 + this.state = emptyState(); + } + return this.state; + } + + persist() { + this.state.updatedAt = domain.iso(this.clock); + if (this.filePath) atomicWriteJson(this.filePath, this.state); + return this.snapshot(); + } + + snapshot() { return clone(this.state); } + + getControl() { return clone(this.state.control); } + + setPaused(paused, input = {}) { + const next = Boolean(paused); + const previous = this.state.control; + this.state.control = { + paused: next, + reason: typeof input.reason === 'string' ? input.reason.slice(0, 1000) : null, + actor: typeof input.actor === 'string' ? input.actor.slice(0, 160) : 'system', + version: Number(previous.version || 0) + (previous.paused === next ? 0 : 1), + updatedAt: domain.iso(this.clock), + }; + this.persist(); + return this.getControl(); + } + + upsertRepository(input) { + const key = input.key || domain.repoKeyFor(input); + const previous = this.state.repositories[key]; + const record = domain.createRepositoryRecord({ ...previous, ...input, key }, { now: this.clock }); + this.state.repositories[key] = record; + this.persist(); + return clone(record); + } + + getRepository(key) { return clone(this.state.repositories[String(key)] || null); } + listRepositories(query = {}) { + return Object.values(this.state.repositories).filter((item) => ( + (!query.instanceId || item.instanceId === query.instanceId) + && (!query.status || item.status === query.status) + && (query.enabled === undefined ? true : item.enabled === Boolean(query.enabled)) + )).map(clone); + } + + upsertSession(input) { + const key = input.sessionKey || domain.sessionKeyFor(input); + const previous = this.state.sessions[key]; + const record = domain.createSessionRecord({ ...previous, ...input, sessionKey: key }, { now: this.clock }); + this.state.sessions[key] = record; + this.persist(); + return clone(record); + } + + getSession(key) { return clone(this.state.sessions[String(key)] || null); } + listSessions(query = {}) { + return Object.values(this.state.sessions).filter((item) => ( + (!query.repoKey || item.resourceKey?.startsWith(String(query.repoKey))) + && (!query.status || item.status === query.status) + )).map(clone); + } + + createTask(input) { + const record = domain.createTaskRecord(input, { now: this.clock }); + if (this.state.tasks[record.taskId]) return clone(this.state.tasks[record.taskId]); + this.state.tasks[record.taskId] = record; + this.persist(); + return clone(record); + } + + upsertTask(input) { + const id = String(input.taskId || input.id || ''); + if (!id) throw new TypeError('任务缺少 taskId'); + const previous = this.state.tasks[id]; + const record = domain.createTaskRecord({ ...previous, ...input, taskId: id }, { now: this.clock }); + this.state.tasks[id] = record; + this.persist(); + return clone(record); + } + + transitionTask(taskId, nextState, options = {}) { + const current = this.state.tasks[String(taskId)]; + if (!current) return null; + if (options.expectedVersion !== undefined + && Number(current.stateVersion || 0) !== Number(options.expectedVersion)) { + const error = new Error('任务版本已变化,请刷新后重试。'); + error.code = 'version_conflict'; + error.expectedVersion = options.expectedVersion; + error.actualVersion = current.stateVersion || 0; + throw error; + } + const next = domain.transitionTask(current, nextState, { ...options, now: options.now || this.clock }); + this.state.tasks[String(taskId)] = next; + this.persist(); + return clone(next); + } + + getTask(taskId) { return clone(this.state.tasks[String(taskId)] || null); } + listTasks(query = {}) { + return Object.values(this.state.tasks).filter((item) => ( + (!query.repoKey || item.repoKey === query.repoKey) + && (!query.sessionKey || item.sessionKey === query.sessionKey) + && (!query.resourceKey || item.resourceKey === query.resourceKey) + && (!query.state && !query.status || item.state === (query.state || query.status)) + )).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))).map(clone); + } + + upsertTurn(input) { + const id = String(input.turnId || ''); + if (!id) throw new TypeError('turn 缺少 turnId'); + const record = { ...(this.state.turns[id] || {}), ...clone(input), turnId: id, updatedAt: domain.iso(this.clock) }; + this.state.turns[id] = record; + this.persist(); + return clone(record); + } + + getTurn(turnId) { return clone(this.state.turns[String(turnId)] || null); } + listTurns(query = {}) { + return Object.values(this.state.turns).filter((item) => (!query.taskId || item.taskId === query.taskId) + && (!query.sessionKey || item.sessionKey === query.sessionKey)).map(clone); + } + + getDelivery(deliveryKey) { return clone(this.state.deliveries[String(deliveryKey)] || null); } + + /** 原子语义由单进程事件循环保证;重复 delivery 永远返回原记录。 */ + claimDelivery(deliveryKey, value = {}) { + const key = String(deliveryKey || ''); + if (!key) throw new TypeError('deliveryKey 不能为空'); + const existing = this.state.deliveries[key]; + if (existing) return { duplicate: true, record: clone(existing) }; + const record = { + deliveryKey: key, + deliveryId: value.deliveryId || key.split(':').slice(1).join(':'), + status: value.status || 'processing', + taskId: value.taskId || null, + createdAt: value.createdAt || domain.iso(this.clock), + updatedAt: value.updatedAt || domain.iso(this.clock), + metadata: sanitizeMetadata(value.metadata || {}), + }; + this.state.deliveries[key] = record; + const keys = Object.keys(this.state.deliveries); + while (keys.length > MAX_DELIVERIES) delete this.state.deliveries[keys.shift()]; + this.persist(); + return { duplicate: false, record: clone(record) }; + } + + updateDelivery(deliveryKey, patch = {}) { + const key = String(deliveryKey); + if (!this.state.deliveries[key]) return null; + this.state.deliveries[key] = { ...this.state.deliveries[key], ...sanitizeMetadata(patch), updatedAt: domain.iso(this.clock) }; + this.persist(); + return clone(this.state.deliveries[key]); + } + + appendAudit(input = {}) { + const item = { + eventId: input.eventId || crypto.randomUUID(), + taskId: input.taskId || null, + sessionKey: input.sessionKey || null, + repoKey: input.repoKey || null, + actor: input.actor || 'system', + action: input.action || 'unknown', + fromState: input.fromState || null, + toState: input.toState || null, + deliveryId: input.deliveryId || null, + turnId: input.turnId || null, + commentId: input.commentId ?? null, + errorCode: input.errorCode || null, + timestamp: input.timestamp || domain.iso(this.clock), + metadata: sanitizeMetadata(input.metadata || {}), + }; + this.state.audits.push(item); + if (this.state.audits.length > MAX_AUDITS) this.state.audits.splice(0, this.state.audits.length - MAX_AUDITS); + this.persist(); + return clone(item); + } + + listAudits(query = {}) { + return this.state.audits.filter((item) => (!query.taskId || item.taskId === query.taskId) + && (!query.sessionKey || item.sessionKey === query.sessionKey) + && (!query.repoKey || item.repoKey === query.repoKey) + && (!query.action || item.action === query.action) + && (!query.from || String(item.timestamp) >= String(query.from)) + && (!query.to || String(item.timestamp) <= String(query.to))).map(clone); + } + + recover(options = {}) { + const before = Object.values(this.state.tasks); + const after = domain.recoverTasks(before, { ...options, now: options.now || this.clock }); + for (const task of after) this.state.tasks[task.taskId] = task; + this.persist(); + return after.map(clone); + } +} + +function createGiteaWorkflowStore(options) { + return new GiteaWorkflowStore(options); +} + +module.exports = { + GiteaWorkflowStore, + atomicWriteJson, + createGiteaWorkflowStore, + emptyState, + sanitizeMetadata, +}; diff --git a/lib/gitea-workspace.js b/lib/gitea-workspace.js new file mode 100644 index 0000000..46c6ddf --- /dev/null +++ b/lib/gitea-workspace.js @@ -0,0 +1,386 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +class WorkspaceError extends Error { + constructor(code, message, details) { + super(message); + this.name = 'WorkspaceError'; + this.code = code; + if (details !== undefined) this.details = details; + } +} + +class BlockedWorkspaceError extends WorkspaceError { + constructor(message, details) { + super('blocked_workspace', message || '工作区存在未提交修改,已阻止覆盖。', details); + this.name = 'BlockedWorkspaceError'; + } +} + +function atomicWriteJson(filePath, value) { + const target = path.resolve(filePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + try { fs.renameSync(tempPath, target); } finally { + try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} + } +} + +function safeSegment(value, label) { + const normalized = String(value || '').trim(); + if (!normalized || normalized === '.' || normalized === '..' || !/^[A-Za-z0-9._-]+$/.test(normalized)) { + throw new WorkspaceError('invalid_repository', `${label} 格式无效。`); + } + return normalized; +} + +function normalizeRepository(repo) { + if (!repo || typeof repo !== 'object') throw new WorkspaceError('invalid_repository', '仓库信息无效。'); + const owner = safeSegment(repo.owner, 'owner'); + const name = safeSegment(repo.name, 'repo'); + const cloneUrl = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim(); + let parsed; + try { parsed = new URL(cloneUrl); } catch { + throw new WorkspaceError('invalid_clone_url', '仓库 clone URL 无效。'); + } + if (parsed.protocol !== 'https:') { + throw new WorkspaceError('invalid_clone_url', '仅允许 HTTPS clone URL。'); + } + parsed.username = ''; + parsed.password = ''; + return { + owner, + name, + fullName: `${owner}/${name}`, + cloneUrl: parsed.toString(), + defaultBranch: String(repo.defaultBranch || '').trim(), + private: repo.private === true, + }; +} + +// Gitea 某些版本在 Webhook 中返回 http clone_url,即使实例的公开地址是 +// https。仅在主机完全匹配且配置地址为 https 时升级协议,避免放宽到任意 +// 不安全的远程地址。 +function upgradeCloneUrlToConfiguredHttps(repo, allowedHost) { + if (!repo || !allowedHost) return repo; + const raw = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim(); + try { + const clone = new URL(raw); + const configured = new URL(allowedHost); + if (clone.protocol === 'http:' && configured.protocol === 'https:' && clone.host === configured.host) { + clone.protocol = 'https:'; + return { ...repo, cloneUrl: clone.toString() }; + } + } catch {} + return repo; +} + +function repoKey(instanceId, repo) { + const normalized = normalizeRepository(repo); + const instance = safeSegment(instanceId || 'default', 'instanceId'); + return `${instance}:${normalized.fullName}`; +} + +function createGitRunner(options = {}) { + if (typeof options.gitRunner === 'function') return options.gitRunner; + const gitCommand = String(options.gitCommand || 'git'); + const maxOutputBytes = Number.isSafeInteger(options.maxOutputBytes) ? options.maxOutputBytes : 2 * 1024 * 1024; + return ({ args, cwd, env }) => new Promise((resolve, reject) => { + const child = spawn(gitCommand, args, { cwd, env, windowsHide: true }); + const stdout = []; + const stderr = []; + let total = 0; + let rejected = false; + const collect = (target) => (chunk) => { + if (rejected) return; + total += chunk.length; + if (total > maxOutputBytes) { + rejected = true; + child.kill('SIGTERM'); + reject(new WorkspaceError('git_output_too_large', 'Git 输出过大。')); + return; + } + target.push(chunk); + }; + child.stdout.on('data', collect(stdout)); + child.stderr.on('data', collect(stderr)); + child.on('error', (error) => { + if (!rejected) reject(new WorkspaceError('git_spawn_failed', error.message)); + }); + child.on('close', (code, signal) => { + if (rejected) return; + const result = { + code: Number.isInteger(code) ? code : 1, + signal: signal || null, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }; + if (result.code !== 0) { + const detail = String(result.stderr || result.stdout || '').trim().replace(/\s+/g, ' ').slice(-2000); + const suffix = detail ? `:${detail}` : `(退出码 ${result.code}${result.signal ? `,信号 ${result.signal}` : ''})`; + const error = new WorkspaceError('git_failed', `Git 命令失败: ${args[0] || 'git'}${suffix}`, result); + reject(error); + return; + } + resolve(result); + }); + }); +} + +async function withTempGitCredentials(options, fn) { + const token = String(options.token || ''); + if (!token) throw new WorkspaceError('missing_git_token', '缺少 Gitea Bot Token。'); + const username = String(options.username || 'ccweb-bot'); + const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccweb-git-askpass-')); + const askpassPath = path.join(tempRoot, process.platform === 'win32' ? 'askpass.cmd' : 'askpass.sh'); + // 凭据只存在子进程环境和临时脚本中,绝不写入 remote URL 或普通日志。 + const script = process.platform === 'win32' + ? [ + '@echo off', + 'echo %1 | findstr /I "user" >nul', + 'if %errorlevel%==0 (echo %CCWEB_GIT_USERNAME%) else (echo %CCWEB_GIT_TOKEN%)', + '', + ].join('\r\n') + : [ + '#!/bin/sh', + 'case "$(printf %s "${1:-}" | tr "[:upper:]" "[:lower:]")" in', + ' *user*) printf %s "${CCWEB_GIT_USERNAME:-}" ;;', + ' *) printf %s "${CCWEB_GIT_TOKEN:-}" ;;', + 'esac', + '', + ].join('\n'); + await fs.promises.writeFile(askpassPath, script, { mode: 0o700 }); + try { + const env = { + ...process.env, + ...(options.env || {}), + GIT_ASKPASS: askpassPath, + GIT_TERMINAL_PROMPT: '0', + CCWEB_GIT_USERNAME: username, + CCWEB_GIT_TOKEN: token, + }; + return await fn(env); + } finally { + try { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } catch {} + } +} + +function createRepositoryLockManager(options = {}) { + const lockRoot = path.resolve(options.lockRoot || path.join(options.workspaceRoot || os.tmpdir(), '.locks')); + const staleLockMs = Number.isSafeInteger(options.staleLockMs) && options.staleLockMs > 0 ? options.staleLockMs : 10 * 60 * 1000; + const retryMs = Number.isSafeInteger(options.retryMs) && options.retryMs > 0 ? options.retryMs : 50; + const waitTimeoutMs = Number.isSafeInteger(options.waitTimeoutMs) && options.waitTimeoutMs > 0 ? options.waitTimeoutMs : 30_000; + const localLocks = new Map(); + + function lockPathFor(key) { + return path.join(lockRoot, `${crypto.createHash('sha256').update(String(key)).digest('hex')}.lock`); + } + + async function acquire(key) { + const normalizedKey = String(key); + const previous = localLocks.get(normalizedKey) || Promise.resolve(); + let releaseLocal; + const current = new Promise((resolve) => { releaseLocal = resolve; }); + const chain = previous.then(() => current); + localLocks.set(normalizedKey, chain); + await previous; + fs.mkdirSync(lockRoot, { recursive: true }); + const lockPath = lockPathFor(normalizedKey); + const startedAt = Date.now(); + let handle; + while (!handle) { + try { + handle = await fs.promises.open(lockPath, 'wx', 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, key: normalizedKey, createdAt: new Date().toISOString() })); + } catch (error) { + if (error.code !== 'EEXIST') { + releaseLocal(); + throw new WorkspaceError('lock_failed', `无法创建仓库锁: ${error.message}`); + } + try { + const stat = await fs.promises.stat(lockPath); + if (Date.now() - stat.mtimeMs > staleLockMs) await fs.promises.unlink(lockPath); + } catch {} + if (Date.now() - startedAt >= waitTimeoutMs) { + releaseLocal(); + throw new WorkspaceError('lock_timeout', '等待仓库锁超时。', { key: normalizedKey }); + } + await new Promise((resolve) => setTimeout(resolve, retryMs)); + } + } + let released = false; + return async () => { + if (released) return; + released = true; + try { await handle.close(); } catch {} + try { await fs.promises.unlink(lockPath); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + releaseLocal(); + if (localLocks.get(normalizedKey) === chain) localLocks.delete(normalizedKey); + }; + } + + async function withLock(key, fn) { + const release = await acquire(key); + try { return await fn(); } finally { await release(); } + } + + return { acquire, withLock, lockPathFor }; +} + +function createGiteaWorkspaceManager(options = {}) { + const workspaceRoot = path.resolve(options.workspaceRoot || process.env.CC_WEB_GITEA_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT || path.join(process.cwd(), 'gitea-workspaces')); + const instanceId = String(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID || 'default'); + const defaultToken = String(options.token || process.env.CC_WEB_GITEA_BOT_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_BOT_TOKEN || ''); + const recordsPath = path.resolve(options.recordsPath || path.join(workspaceRoot, 'repositories.json')); + const botUsername = String(options.botUsername || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN || 'ccweb-bot'); + const allowedHost = String(options.allowedHost || process.env.CC_WEB_GITEA_HOST || process.env.GITEA_HOST || '').trim().replace(/\/+$/, ''); + const gitRunner = createGitRunner(options); + const locks = options.lockManager || createRepositoryLockManager({ workspaceRoot, ...options }); + let records = {}; + try { + const parsed = JSON.parse(fs.readFileSync(recordsPath, 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) records = parsed.repositories || parsed; + } catch {} + + function workspacePath(repo) { + const normalized = normalizeRepository(repo); + return path.join(workspaceRoot, safeSegment(instanceId, 'instanceId'), normalized.owner, normalized.name); + } + + function persistRecords() { + atomicWriteJson(recordsPath, { schemaVersion: 1, repositories: records }); + } + + async function git(repoPath, args, token, extraEnv = {}) { + return withTempGitCredentials({ token, username: botUsername, env: extraEnv }, (env) => gitRunner({ args, cwd: repoPath, env })); + } + + async function inspect(repoPath, token) { + const result = await git(repoPath, ['status', '--porcelain', '--untracked-files=all'], token); + const output = String(result.stdout || ''); + if (output.trim()) { + const conflict = output.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + throw new BlockedWorkspaceError(conflict ? '工作区存在 Git 冲突,已阻止继续。' : undefined, { status: output }); + } + return { clean: true }; + } + + async function ensureRepository(repo, options = {}) { + const normalized = normalizeRepository(upgradeCloneUrlToConfiguredHttps(repo, allowedHost)); + if (allowedHost) { + let configured; + try { configured = new URL(allowedHost); } catch { throw new WorkspaceError('invalid_gitea_host', '配置的 Gitea 地址无效。'); } + const clone = new URL(normalized.cloneUrl); + if (configured.protocol !== clone.protocol || configured.host !== clone.host) { + throw new WorkspaceError('clone_host_mismatch', '仓库 clone 地址与配置的 Gitea 实例不一致。', { + configuredHost: configured.host, + cloneHost: clone.host, + }); + } + } + const key = repoKey(instanceId, normalized); + const repoPath = workspacePath(normalized); + const token = String(options.token || defaultToken || ''); + return locks.withLock(key, async () => { + const existing = records[key]; + const now = new Date().toISOString(); + if (!existing) { + records[key] = { + key, + instanceId, + owner: normalized.owner, + name: normalized.name, + fullName: normalized.fullName, + cloneUrl: normalized.cloneUrl, + workspacePath: repoPath, + status: 'preparing', + createdAt: now, + updatedAt: now, + }; + persistRecords(); + } + fs.mkdirSync(path.dirname(repoPath), { recursive: true }); + const gitDir = path.join(repoPath, '.git'); + if (!fs.existsSync(gitDir)) { + if (fs.existsSync(repoPath) && fs.readdirSync(repoPath).length > 0) { + throw new BlockedWorkspaceError('目标工作区目录非空且不是 Git 仓库。', { workspacePath: repoPath }); + } + // 工作流只需要默认分支的当前代码。浅克隆显著降低首次接入的传输量, + // 避免 Gitea/反向代理在完整历史传输期间中断;后续 fetch 仍复用同一工作区。 + const cloneArgs = ['clone', '--origin', 'origin', '--depth', '1', '--single-branch']; + if (normalized.defaultBranch) cloneArgs.push('--branch', normalized.defaultBranch); + cloneArgs.push(normalized.cloneUrl, repoPath); + await git(workspaceRoot, cloneArgs, token); + } else { + if (options.allowDirty === true) { + return { + ...records[key], + key, + cloneUrl: normalized.cloneUrl, + workspacePath: repoPath, + status: 'dirty_ready', + dirty: true, + dirtyReason: records[key]?.dirtyReason || '沿用当前讨论的未提交修改。', + dirtySessionKey: options.dirtySessionKey || records[key]?.dirtySessionKey || null, + repository: normalized, + }; + } + await inspect(repoPath, token); + await git(repoPath, ['fetch', '--prune', 'origin'], token); + if (normalized.defaultBranch) { + // 仅在工作区确认干净后快进到配置的基线分支,绝不 reset/覆盖用户修改。 + await git(repoPath, ['checkout', normalized.defaultBranch], token); + await git(repoPath, ['merge', '--ff-only', `origin/${normalized.defaultBranch}`], token); + } + } + records[key] = { + ...(records[key] || {}), + key, + cloneUrl: normalized.cloneUrl, + defaultBranch: normalized.defaultBranch || records[key]?.defaultBranch || '', + workspacePath: repoPath, + status: 'ready', + dirty: false, + dirtyReason: null, + dirtySessionKey: null, + updatedAt: new Date().toISOString(), + }; + persistRecords(); + return { ...records[key], repository: normalized }; + }); + } + + return { + workspaceRoot, + recordsPath, + repoKey: (repo) => repoKey(instanceId, repo), + workspacePath, + ensureRepository, + ensureWorkspace: ensureRepository, + inspectWorkspace: inspect, + checkDirty: inspect, + lockManager: locks, + withRepositoryLock: locks.withLock, + getRecord: (repo) => records[repoKey(instanceId, repo)] || null, + }; +} + +module.exports = { + BlockedWorkspaceError, + WorkspaceError, + createWorkspaceManager: createGiteaWorkspaceManager, + createGiteaWorkspaceManager, + createGitRunner, + createRepositoryLockManager, + normalizeRepository, + repoKey, + withTempGitCredentials, +}; diff --git a/public/app.js b/public/app.js index d0cc6ca..f1608d5 100644 --- a/public/app.js +++ b/public/app.js @@ -256,6 +256,7 @@ let directoryPickerState = null; let codexAppUserInputModal = null; let codexAppApprovalModal = null; + let codexAppElicitationModal = null; let pendingNewSessionRequest = null; let pendingSessionSwitchRequest = null; let pendingSessionResumeRequest = null; @@ -1462,6 +1463,30 @@ return sessions.find((s) => s.id === sessionId) || null; } + function normalizeGiteaSource(source) { + if (!source || typeof source !== 'object' || Array.isArray(source)) return null; + const repository = String(source.repository || source.repo || '').trim().slice(0, 240); + const resourceKind = String(source.resourceKind || source.kind || '').trim().toLowerCase(); + const number = Number(source.number); + const title = String(source.title || '').replace(/\s+/g, ' ').trim().slice(0, 240); + if (!repository && (!Number.isSafeInteger(number) || number <= 0) && !title) return null; + return { + source: 'gitea', + label: 'Gitea', + repository, + resourceKind: resourceKind === 'pull_request' || resourceKind === 'pr' ? 'pull_request' : 'issue', + number: Number.isSafeInteger(number) && number > 0 ? number : null, + title, + }; + } + + function giteaResourceLabel(source) { + const normalized = normalizeGiteaSource(source); + if (!normalized) return 'Gitea'; + const kind = normalized.resourceKind === 'pull_request' ? 'PR' : 'Issue'; + return [normalized.repository, normalized.number ? `${kind} #${normalized.number}` : ''].filter(Boolean).join(' · ') || 'Gitea'; + } + function compareSessionUpdatedDesc(a, b) { return new Date(b?.updated || 0) - new Date(a?.updated || 0); } @@ -2194,6 +2219,7 @@ pinnedAt: payload.pinnedAt || null, titleSource: payload.titleSource || null, createdFromKind: payload.createdFromKind || null, + giteaSource: normalizeGiteaSource(payload.giteaSource), hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, projectName: payload.projectName || '', @@ -2310,6 +2336,7 @@ pinnedAt: snapshot.pinnedAt || null, titleSource: snapshot.titleSource || null, createdFromKind: snapshot.createdFromKind || null, + giteaSource: normalizeGiteaSource(snapshot.giteaSource), hasUnread: !!snapshot.hasUnread, isRunning: !!snapshot.isRunning, waitingOnChildren: !!snapshot.waitingOnChildren, @@ -2336,6 +2363,7 @@ title: nextMeta.title || session.title, titleSource: nextMeta.titleSource || session.titleSource || null, createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null, + giteaSource: nextMeta.giteaSource || session.giteaSource || null, }; }); if (!found) { @@ -2364,6 +2392,7 @@ snapshot.hasUnread = !!meta.hasUnread; snapshot.updated = meta.updated || snapshot.updated; snapshot.pinnedAt = meta.pinnedAt || null; + snapshot.giteaSource = normalizeGiteaSource(meta.giteaSource); snapshot.isRunning = !!meta.isRunning; snapshot.waitingOnChildren = !!meta.waitingOnChildren; snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0); @@ -2684,6 +2713,124 @@ overlay.querySelector('[data-codex-approval-action="approve"]')?.focus(); } + function closeCodexAppElicitationModal(sendCancel = false) { + if (!codexAppElicitationModal) return; + const { overlay, escapeHandler, requestId, sessionId } = codexAppElicitationModal; + if (escapeHandler) document.removeEventListener('keydown', escapeHandler); + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + codexAppElicitationModal = null; + if (sendCancel && requestId) { + send({ + type: 'codex_app_elicitation_response', + action: 'cancel', + sessionId, + requestId, + content: null, + _meta: null, + }); + } + } + + function submitCodexAppElicitation(action) { + if (!codexAppElicitationModal) return; + const { requestId, sessionId, panel, fields, mode } = codexAppElicitationModal; + const content = action === 'accept' && mode !== 'url' && panel ? collectCodexAppElicitationContent(panel, fields) : null; + send({ + type: 'codex_app_elicitation_response', + action, + sessionId, + requestId, + content, + _meta: null, + }); + closeCodexAppElicitationModal(false); + } + + function codexAppElicitationOptions(schema = {}) { + if (Array.isArray(schema.enum)) return schema.enum.map((value) => ({ value, label: value })); + if (Array.isArray(schema.oneOf)) { + return schema.oneOf + .filter((item) => item && Object.prototype.hasOwnProperty.call(item, 'const')) + .map((item) => ({ value: item.const, label: item.title || item.const })); + } + return []; + } + + function renderCodexAppElicitationField(id, schema = {}, required = false) { + const type = String(schema.type || 'string'); + const options = codexAppElicitationOptions(schema); + const escapedId = escapeHtml(id); + const title = schema.title || id; + const description = schema.description ? `${escapeHtml(schema.description)}` : ''; + let control = ''; + if (type === 'boolean') { + control = ``; + } else if (type === 'array' && (schema.items?.enum || schema.items?.anyOf)) { + const itemOptions = codexAppElicitationOptions(schema.items || {}); + control = itemOptions.map((option, index) => ``).join(''); + } else if (options.length > 0) { + control = ``; + } else { + const inputType = type === 'number' || type === 'integer' ? 'number' : (schema.format === 'email' ? 'email' : (schema.format === 'date' ? 'date' : (schema.format === 'date-time' ? 'datetime-local' : (schema.format === 'uri' ? 'url' : 'text')))); + const isLong = type === 'object' || schema.format === 'multiline' || Number(schema.maxLength || 0) > 180; + const value = schema.default === undefined ? '' : (typeof schema.default === 'string' ? schema.default : JSON.stringify(schema.default)); + control = isLong + ? `` + : ``; + } + return ``; + } + + function collectCodexAppElicitationContent(panel, fields = []) { + const content = {}; + for (const field of fields) { + const id = String(field.id || '').trim(); + if (!id) continue; + const selector = `[data-codex-elicit-field="${cssEscape(id)}"]`; + const controls = Array.from(panel.querySelectorAll(selector)); + if (!controls.length) continue; + const type = String(field.schema?.type || controls[0].dataset.codexElicitType || 'string'); + if (type === 'boolean') { + content[id] = controls[0].checked; + } else if (type === 'array') { + content[id] = controls.filter((control) => control.checked).map((control) => control.value); + } else { + const value = controls[0].value; + if (type === 'number' || type === 'integer') content[id] = value === '' ? null : Number(value); + else if (type === 'object') { + try { content[id] = value ? JSON.parse(value) : {}; } catch { content[id] = value; } + } else content[id] = value; + } + } + return content; + } + + function showCodexAppElicitationModal(msg) { + closeCodexAppElicitationModal(true); + const mode = String(msg.mode || 'form'); + const schema = msg.requestedSchema && typeof msg.requestedSchema === 'object' ? msg.requestedSchema : {}; + const safeUrl = /^https?:\/\//i.test(String(msg.url || '')) ? String(msg.url) : ''; + const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const fields = Object.entries(properties).map(([id, fieldSchema]) => ({ id, schema: fieldSchema || {} })); + const formHtml = mode === 'url' + ? `
请在新页面完成 ${escapeHtml(msg.serverName || 'MCP 服务')} 的操作,然后返回此处确认。
${safeUrl ? `打开授权页面` : ''}` + : (fields.length ? fields.map((field) => renderCodexAppElicitationField(field.id, field.schema, required.has(field.id))).join('') : ''); + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay codex-elicitation-overlay'; + overlay.innerHTML = ``; + document.body.appendChild(overlay); + const panel = overlay.querySelector('.codex-elicitation-panel'); + const escapeHandler = (event) => { if (event.key === 'Escape') closeCodexAppElicitationModal(true); }; + document.addEventListener('keydown', escapeHandler); + codexAppElicitationModal = { overlay, panel, fields, mode, requestId: msg.requestId || '', sessionId: msg.sessionId || '', escapeHandler }; + overlay.querySelectorAll('[data-codex-elicitation-cancel]').forEach((button) => button.addEventListener('click', () => closeCodexAppElicitationModal(true))); + overlay.addEventListener('click', (event) => { if (event.target === overlay) closeCodexAppElicitationModal(true); }); + overlay.querySelector('[data-codex-elicitation-decline]')?.addEventListener('click', () => submitCodexAppElicitation('decline')); + overlay.querySelector('[data-codex-elicitation-accept]')?.addEventListener('click', () => submitCodexAppElicitation('accept')); + overlay.querySelector('input, select, textarea, button')?.focus(); + } + function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || '')); return String(value || '').replace(/["\\]/g, '\\$&'); @@ -5703,15 +5850,17 @@ const item = document.createElement('div'); const isPinned = !!session.pinnedAt; const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp'; + const giteaSource = normalizeGiteaSource(session.giteaSource); const waitingOnChildren = !!session.waitingOnChildren; const readyReplyCount = Number(session.readyReplyCount || 0); const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim(); - item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; + item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${giteaSource ? ' gitea-session' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; item.dataset.id = session.id; const sessionProjectName = getSessionProjectName(session); item.title = buildSessionItemTooltip(sessionProjectName, session.title); item.innerHTML = `
+ ${giteaSource ? `Gitea` : ''} ${escapeHtml(session.title || 'Untitled')} ${isPinned ? '' : ''} ${session.isRunning ? '运行中' : ''} @@ -7220,11 +7369,13 @@ title: msg.title, ...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}), ...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}), + ...(msg.giteaSource !== undefined ? { giteaSource: normalizeGiteaSource(msg.giteaSource) } : {}), } : session); updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null; if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null; + if (msg.giteaSource !== undefined) snapshot.giteaSource = normalizeGiteaSource(msg.giteaSource); if (msg.titleEvent) { snapshot.titleHistory = normalizeOutlineTitleHistory([...(snapshot.titleHistory || []), msg.titleEvent]); } @@ -7367,6 +7518,13 @@ showCodexAppApprovalModal(msg); break; + case 'codex_app_elicitation_request': + if (msg.sessionId && msg.sessionId !== currentSessionId) { + showToast('MCP 请求输入', msg.sessionId); + } + showCodexAppElicitationModal(msg); + break; + case 'ccweb_mcp_child_agent_update': applyCcwebMcpChildAgentUpdate(msg); break; @@ -8002,14 +8160,30 @@ return wrap; } + function getGiteaVisibleMessageContent(content) { + const raw = String(content || '').trim(); + if (!raw) return '来自 gitea 消息:未提供具体指令,请读取工单上下文。'; + if (/^来自\s*gitea\s*消息[::]/i.test(raw)) return raw; + // 兼容旧会话中已经保存的完整 Workflow Prompt,只提取用户指令段。 + const match = raw.match(/用户指令:\s*\n([\s\S]*?)(?:\n\n完成后必须使用官方 gitea-mcp|\n\n如果需要用户补充信息|$)/); + const instruction = String(match?.[1] || '').trim(); + return `来自 gitea 消息:${instruction || '已收到工单请求,请读取工单上下文。'}`; + } + function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); const isCrossConversation = !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply; const isGoalMessage = role === 'user' && meta?.ccwebGoalCommand?.action === 'set'; + const hasGiteaPrompt = role === 'user' && typeof content === 'string' + && /你正在执行\s*cc-web\s*Gitea\s*Workflow\s*任务/i.test(content); + const giteaSource = normalizeGiteaSource(meta.giteaSource) + || (hasGiteaPrompt ? { source: 'gitea', label: 'Gitea', repository: '', resourceKind: 'issue', number: null, title: '' } : null); + const isGiteaMessage = role === 'user' && !!giteaSource; + const visibleContent = isGiteaMessage ? getGiteaVisibleMessageContent(content) : content; const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); - div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}`; + div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}${isGiteaMessage ? ' gitea-message' : ''}`; if (role === 'user') { div.id = `hapi-message-${resolvedMessageId}`; div.dataset.messageId = resolvedMessageId; @@ -8139,6 +8313,14 @@ } if (role === 'user') { + if (isGiteaMessage) { + const giteaLabel = document.createElement('div'); + giteaLabel.className = 'gitea-message-label'; + giteaLabel.textContent = 'Gitea'; + giteaLabel.title = giteaResourceLabel(giteaSource); + giteaLabel.setAttribute('aria-label', `消息来源:${giteaResourceLabel(giteaSource)}`); + bubble.appendChild(giteaLabel); + } if (isGoalMessage) { const goalLabel = document.createElement('div'); goalLabel.className = 'goal-message-label'; @@ -8146,11 +8328,11 @@ goalLabel.setAttribute('aria-label', '目标模式'); bubble.appendChild(goalLabel); } - if (content) { + if (visibleContent) { const textNode = document.createElement('div'); textNode.className = 'msg-text'; textNode.style.whiteSpace = 'pre-wrap'; - textNode.textContent = content; + textNode.textContent = visibleContent; bubble.appendChild(textNode); const copyBtn = document.createElement('button'); @@ -8166,7 +8348,7 @@ `; copyBtn.addEventListener('click', (event) => { event.stopPropagation(); - copyTextToClipboard(content, '用户消息已复制'); + copyTextToClipboard(visibleContent, '用户消息已复制'); }); bubble.appendChild(copyBtn); } @@ -8203,7 +8385,7 @@ setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage); } if (role === 'user') { - registerUserMessage(resolvedMessageId, div, content, meta.timestamp); + registerUserMessage(resolvedMessageId, div, visibleContent, meta.timestamp); } return div; } @@ -10474,6 +10656,7 @@ active: session?.id === currentSessionId, pinnedAt: session?.pinnedAt || '', createdFromKind: String(session?.createdFromKind || '').toLowerCase(), + giteaSource: session?.giteaSource ? JSON.stringify(session.giteaSource) : '', isRunning: !!session?.isRunning, hasUnread: !!session?.hasUnread, waitingOnChildren: !!session?.waitingOnChildren, diff --git a/public/gitea-workflow.css b/public/gitea-workflow.css new file mode 100644 index 0000000..767b9ef --- /dev/null +++ b/public/gitea-workflow.css @@ -0,0 +1,476 @@ +.gitea-workflow-panel { + --gitea-workflow-shell-bg: var(--bg-primary, #f7f8fa); + --gitea-workflow-header-bg: var(--surface-strong, #ffffff); + --gitea-workflow-surface: var(--surface-strong, #ffffff); + --gitea-workflow-surface-soft: var(--bg-secondary, #f1f3f5); + --gitea-workflow-surface-strong: color-mix(in srgb, var(--surface-strong, #ffffff) 92%, var(--bg-primary, #f7f8fa)); + --gitea-workflow-border: var(--border-color, #cbd5e1); + --gitea-workflow-border-soft: color-mix(in srgb, var(--border-color, #cbd5e1) 62%, transparent); + --gitea-workflow-text: var(--text-primary, #1f2937); + --gitea-workflow-secondary: var(--text-secondary, #4b5563); + --gitea-workflow-muted: var(--text-muted, #64748b); + --gitea-workflow-accent: var(--accent, #4678da); + --gitea-workflow-success: var(--success, #257942); + --gitea-workflow-danger: var(--danger, #b33c45); + --gitea-workflow-info: var(--info, #5b7ea1); + --gitea-workflow-accent-soft: color-mix(in srgb, var(--gitea-workflow-accent) 14%, var(--surface-strong, #ffffff)); + --gitea-workflow-accent-soft-strong: color-mix(in srgb, var(--gitea-workflow-accent) 22%, var(--surface-strong, #ffffff)); + --gitea-workflow-success-soft: color-mix(in srgb, var(--gitea-workflow-success) 16%, var(--surface-strong, #ffffff)); + --gitea-workflow-danger-soft: color-mix(in srgb, var(--gitea-workflow-danger) 16%, var(--surface-strong, #ffffff)); + --gitea-workflow-info-soft: color-mix(in srgb, var(--gitea-workflow-info) 16%, var(--surface-strong, #ffffff)); + --gitea-workflow-muted-soft: color-mix(in srgb, var(--gitea-workflow-muted) 12%, var(--surface-strong, #ffffff)); + --gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 6%, var(--surface-strong, #ffffff)); + --gitea-workflow-shadow: 0 24px 56px rgba(0, 0, 0, 0.14); + position: absolute; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--gitea-workflow-shell-bg); + color: var(--gitea-workflow-text); + color-scheme: inherit; + isolation: isolate; +} + +:is(html[data-theme='washi'], html[data-theme='editorial'], html[data-theme='sage'], html[data-theme='ink'], html[data-theme='dawn']) .gitea-workflow-panel { + --gitea-workflow-shell-bg: color-mix(in srgb, var(--bg-primary, #f7f8fa) 94%, var(--surface-strong, #ffffff) 6%); + --gitea-workflow-header-bg: color-mix(in srgb, var(--surface-strong, #ffffff) 92%, var(--bg-primary, #f7f8fa) 8%); + --gitea-workflow-surface: color-mix(in srgb, var(--surface-strong, #ffffff) 94%, var(--bg-secondary, #f1f3f5) 6%); + --gitea-workflow-surface-soft: color-mix(in srgb, var(--bg-secondary, #f1f3f5) 86%, var(--surface-strong, #ffffff) 14%); + --gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 5%, var(--surface-strong, #ffffff)); + --gitea-workflow-shadow: 0 22px 50px rgba(0, 0, 0, 0.12); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder'], html[data-theme='wasteland']) .gitea-workflow-panel { + --gitea-workflow-shell-bg: color-mix(in srgb, var(--bg-primary, #0f1314) 94%, var(--surface-strong, #171d1e) 6%); + --gitea-workflow-header-bg: color-mix(in srgb, var(--surface-strong, #171d1e) 92%, var(--bg-secondary, #151a1b) 8%); + --gitea-workflow-surface: color-mix(in srgb, var(--surface-strong, #171d1e) 96%, var(--bg-secondary, #151a1b) 4%); + --gitea-workflow-surface-soft: color-mix(in srgb, var(--bg-secondary, #151a1b) 80%, var(--surface-strong, #171d1e) 20%); + --gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 8%, var(--surface-strong, #171d1e)); + --gitea-workflow-shadow: 0 30px 72px rgba(0, 0, 0, 0.36); +} + +.gitea-workflow-panel[hidden] { display: none; } + +.gitea-workflow-panel__header { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: .9rem; + min-width: 0; + padding: .9rem 1.2rem; + border-bottom: 1px solid var(--gitea-workflow-border); + background: var(--gitea-workflow-header-bg); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--surface-strong, #ffffff) 62%, transparent); +} + +.gitea-workflow-panel__header > div { + flex: 1; + min-width: 0; +} + +.gitea-workflow-panel__header h2 { + margin: 0; + font-size: 1.05rem; + line-height: 1.25; + letter-spacing: .01em; +} + +.gitea-workflow-panel__header p { + margin: .16rem 0 0; + color: var(--gitea-workflow-secondary); + font-size: .78rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.gitea-workflow-panel__root { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable both-edges; + padding: 1rem clamp(1rem, 3vw, 2.4rem) 2.4rem; +} + +.gitea-workflow__refresh, +.gitea-workflow__button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .28rem; + min-height: 2.35rem; + padding: .42rem .78rem; + border: 1px solid var(--gitea-workflow-border); + border-radius: .6rem; + background: var(--gitea-workflow-surface); + color: var(--gitea-workflow-text); + cursor: pointer; + font: inherit; + font-size: .8rem; + font-weight: 600; + line-height: 1.1; + text-align: center; + text-decoration: none; + transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, transform .16s ease; +} + +.gitea-workflow__refresh:hover:not(:disabled), +.gitea-workflow__button:hover:not(:disabled) { + border-color: var(--gitea-workflow-accent); + background: var(--gitea-workflow-row-hover); +} + +.gitea-workflow__refresh:focus-visible, +.gitea-workflow__button:focus-visible, +.gitea-workflow__config-grid input:focus-visible, +.gitea-workflow__operator input:focus-visible, +.gitea-workflow__reason input:focus-visible { + outline: none; + border-color: var(--gitea-workflow-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gitea-workflow-accent) 22%, transparent); +} + +.gitea-workflow__refresh:disabled, +.gitea-workflow__button:disabled { + opacity: .6; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.gitea-workflow__refresh { + white-space: nowrap; +} + +.gitea-workflow__toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.gitea-workflow__toolbar > div:first-child { + min-width: 0; +} + +.gitea-workflow__toolbar strong { + font-size: .98rem; + line-height: 1.3; +} + +.gitea-workflow__toolbar small { + color: var(--gitea-workflow-secondary); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.gitea-workflow__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: .45rem; + flex-wrap: wrap; + min-width: 0; +} + +.gitea-workflow__config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: .75rem; + padding: .9rem; +} + +.gitea-workflow__config-grid label { + display: flex; + flex-direction: column; + gap: .28rem; + min-width: 0; + color: var(--gitea-workflow-secondary); + font-size: .72rem; +} + +.gitea-workflow__config-grid input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--gitea-workflow-border); + border-radius: .48rem; + padding: .45rem .58rem; + background: var(--gitea-workflow-surface); + color: var(--gitea-workflow-text); + font: inherit; + line-height: 1.3; + transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease, color .16s ease; +} + +.gitea-workflow__config-grid input::placeholder { + color: var(--gitea-workflow-muted); + opacity: 1; +} + +.gitea-workflow__config-actions { + grid-column: 1 / -1; + display: flex; + align-items: center; + min-width: 0; + justify-content: flex-end; +} + +.gitea-workflow__button[data-danger] { + border-color: color-mix(in srgb, var(--gitea-workflow-danger) 30%, var(--gitea-workflow-border)); + background: var(--gitea-workflow-danger-soft); + color: var(--gitea-workflow-danger); +} + +.gitea-workflow__button[data-danger]:hover:not(:disabled) { + border-color: var(--gitea-workflow-danger); + background: color-mix(in srgb, var(--gitea-workflow-danger) 22%, var(--surface-strong, #ffffff)); +} + +.gitea-workflow__metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: .72rem; + margin-bottom: 1rem; +} + +.gitea-workflow__metric { + display: flex; + flex-direction: column; + gap: .18rem; + min-width: 0; + padding: .82rem .9rem; + border: 1px solid var(--gitea-workflow-border); + border-radius: .72rem; + background: + linear-gradient(180deg, var(--gitea-workflow-surface), var(--gitea-workflow-surface-soft)); + box-shadow: 0 1px 0 color-mix(in srgb, var(--surface-strong, #ffffff) 72%, transparent); +} + +.gitea-workflow__metric span { + display: block; + color: var(--gitea-workflow-secondary); + font-size: .72rem; + line-height: 1.35; +} + +.gitea-workflow__metric strong { + display: block; + margin-top: .16rem; + font-size: 1.38rem; + line-height: 1.1; + letter-spacing: -.02em; +} + +.gitea-workflow__section { + margin-top: 1rem; + border: 1px solid var(--gitea-workflow-border); + border-radius: .78rem; + background: var(--gitea-workflow-surface); + box-shadow: var(--gitea-workflow-shadow); + overflow: hidden; +} + +.gitea-workflow__section h3 { + margin: 0; + padding: .78rem .95rem; + border-bottom: 1px solid var(--gitea-workflow-border); + background: color-mix(in srgb, var(--gitea-workflow-surface-soft) 72%, var(--gitea-workflow-surface) 28%); + font-size: .85rem; + line-height: 1.35; +} + +.gitea-workflow__table-wrap { + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.gitea-workflow__table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: .78rem; +} + +.gitea-workflow__table th, +.gitea-workflow__table td { + padding: .62rem .72rem; + border-bottom: 1px solid var(--gitea-workflow-border-soft); + text-align: left; + vertical-align: top; +} + +.gitea-workflow__table th { + color: var(--gitea-workflow-secondary); + font-size: .7rem; + font-weight: 600; + letter-spacing: .01em; + white-space: nowrap; +} + +.gitea-workflow__table td { + color: var(--gitea-workflow-text); + overflow-wrap: anywhere; +} + +.gitea-workflow__table tbody tr { + transition: background-color .14s ease; +} + +.gitea-workflow__table tbody tr:hover td { + background: var(--gitea-workflow-row-hover); +} + +.gitea-workflow__table tr:last-child td { + border-bottom: 0; +} + +.gitea-workflow__mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: .72rem; + overflow-wrap: anywhere; +} + +.gitea-workflow__muted { + color: var(--gitea-workflow-muted); +} + +.gitea-workflow__pill { + display: inline-flex; + align-items: center; + gap: .28rem; + min-height: 1.45rem; + padding: .18rem .5rem; + border: 1px solid transparent; + border-radius: 999px; + background: var(--gitea-workflow-accent-soft); + color: var(--gitea-workflow-accent); + white-space: nowrap; +} + +.gitea-workflow__pill[data-state="active"], +.gitea-workflow__pill[data-state="enabled"], +.gitea-workflow__pill[data-state="online"], +.gitea-workflow__pill[data-state="ready"] { + background: var(--gitea-workflow-success-soft); + color: var(--gitea-workflow-success); +} + +.gitea-workflow__pill[data-state="queued"], +.gitea-workflow__pill[data-state="preparing"] { + background: var(--gitea-workflow-info-soft); + color: var(--gitea-workflow-info); +} + +.gitea-workflow__pill[data-state="running"], +.gitea-workflow__pill[data-state="verifying_reply"] { + background: color-mix(in srgb, var(--gitea-workflow-info) 18%, var(--surface-strong, #ffffff)); + color: var(--gitea-workflow-info); +} + +.gitea-workflow__pill[data-state="waiting_user"], +.gitea-workflow__pill[data-state="retry_wait"] { + background: color-mix(in srgb, var(--gitea-workflow-accent) 18%, var(--surface-strong, #ffffff)); + color: var(--gitea-workflow-accent); +} + +.gitea-workflow__pill[data-state="blocked_workspace"], +.gitea-workflow__pill[data-state="failed"], +.gitea-workflow__pill[data-state="failed_reply"], +.gitea-workflow__pill[data-state="aborting"] { + background: var(--gitea-workflow-danger-soft); + color: var(--gitea-workflow-danger); +} + +.gitea-workflow__pill[data-state="succeeded"], +.gitea-workflow__pill[data-state="succeeded_with_rest_fallback"] { + background: var(--gitea-workflow-success-soft); + color: var(--gitea-workflow-success); +} + +.gitea-workflow__pill[data-state="cancelled"], +.gitea-workflow__pill[data-state="aborted"], +.gitea-workflow__pill[data-state="disabled"] { + background: var(--gitea-workflow-muted-soft); + color: var(--gitea-workflow-muted); +} + +.gitea-workflow__dirty { + max-width: 18rem; + color: color-mix(in srgb, var(--gitea-workflow-accent) 58%, var(--gitea-workflow-secondary)); + overflow-wrap: anywhere; +} + +.gitea-workflow__log { + max-height: 14rem; + overflow: auto; + padding: .2rem .9rem .7rem; +} + +.gitea-workflow__log-entry { + display: grid; + grid-template-columns: minmax(9rem, 10rem) minmax(4.25rem, 5rem) minmax(0, 1fr); + gap: .55rem; + padding: .42rem 0; + border-bottom: 1px dashed var(--gitea-workflow-border-soft); + font-size: .74rem; +} + +.gitea-workflow__log-entry:last-child { + border-bottom: 0; +} + +.gitea-workflow__empty { + padding: 1rem; + color: var(--gitea-workflow-muted); + text-align: center; + font-size: .8rem; +} + +.gitea-workflow__error { + margin-bottom: .75rem; + padding: .68rem .82rem; + border: 1px solid color-mix(in srgb, var(--gitea-workflow-danger) 28%, var(--gitea-workflow-border)); + border-radius: .6rem; + background: var(--gitea-workflow-danger-soft); + color: color-mix(in srgb, var(--gitea-workflow-danger) 82%, var(--gitea-workflow-text)); + font-size: .8rem; + line-height: 1.45; +} + +@media (max-width: 900px) { + .gitea-workflow__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .gitea-workflow-panel__header { + padding: .8rem .85rem; + } + + .gitea-workflow-panel__root { + padding: .85rem .8rem 1.35rem; + } + + .gitea-workflow__toolbar { + gap: .8rem; + } + + .gitea-workflow__config-grid { + grid-template-columns: 1fr; + } + + .gitea-workflow__log-entry { + grid-template-columns: 1fr; + gap: .12rem; + } + + .gitea-workflow__table { + min-width: 720px; + } +} diff --git a/public/gitea-workflow.js b/public/gitea-workflow.js new file mode 100644 index 0000000..75d1b47 --- /dev/null +++ b/public/gitea-workflow.js @@ -0,0 +1,161 @@ +(() => { + 'use strict'; + + const $ = (selector) => document.querySelector(selector); + const openButton = $('#gitea-workflow-open'); + const panel = $('#gitea-workflow-panel'); + const root = $('#gitea-workflow-root'); + const status = $('#gitea-workflow-status'); + const refreshButton = $('#gitea-workflow-refresh'); + const closeButton = $('#gitea-workflow-close'); + if (!openButton || !panel || !root) return; + + const SYSTEM_AUDIT_ACTOR = 'system'; + const SYSTEM_AUDIT_REASON = 'cc-web 管理页自动操作'; + + let overview = null; + let loading = false; + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + .replaceAll('"', '"').replaceAll("'", '''); + } + + function token() { return localStorage.getItem('cc-web-token') || ''; } + function api(path, init = {}) { + const headers = { ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers || {}) }; + if (token()) headers.Authorization = `Bearer ${token()}`; + return fetch(path, { ...init, headers }).then(async (response) => { + const data = await response.json().catch(() => ({})); + if (!response.ok) throw Object.assign(new Error(data.message || data.code || `HTTP ${response.status}`), { response, data }); + return data; + }); + } + + function stateLabel(task) { + return task.stateLabel || ({ queued: '排队中', preparing: '准备中', running: '运行中', waiting_user: '等待用户', + verifying_reply: '核验回帖', retry_wait: '等待重试', blocked_workspace: '工作区阻塞', aborting: '中止中', + succeeded: '已完成', succeeded_with_rest_fallback: '已完成(REST 兜底)', failed: '失败', failed_reply: '回帖失败', + cancelled: '已取消', aborted: '已中止' }[task.state] || task.state); + } + + function date(value) { + if (!value) return '—'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? escapeHtml(value) : escapeHtml(parsed.toLocaleString()); + } + + function actionLabel(value) { + const labels = { + 'admin.pause': '暂停工作流', + 'admin.resume': '恢复工作流', + 'admin.enable': '启用仓库', + 'admin.disable': '停用仓库', + 'admin.cancel': '取消排队', + 'admin.abort': '中止 Turn', + 'admin.configuration_updated': '更新配置', + }; + return labels[value] || String(value || '工作流操作').replace(/^admin\./, ''); + } + + function systemAuditFields(requestId = '') { + const payload = { + actor: SYSTEM_AUDIT_ACTOR, + reason: SYSTEM_AUDIT_REASON, + }; + if (requestId) payload.requestId = requestId; + return payload; + } + + function actionButtonLabel(paused) { + return paused ? '恢复全局' : '暂停全局'; + } + + function render() { + const data = overview || { summary: {}, control: {}, repositories: [], tasks: [], audits: [], logs: [] }; + const control = data.control || {}; + const summary = data.summary || {}; + const paused = !!control.globalPaused; + const repositories = Array.isArray(data.repositories) ? data.repositories : []; + const tasks = Array.isArray(data.tasks) ? data.tasks : []; + const audits = Array.isArray(data.audits) ? data.audits : []; + const logs = Array.isArray(data.logs) ? data.logs : []; + const settings = data.settings || {}; + root.innerHTML = `
+
${paused ? '全局已暂停' : '工作流运行中'}
${paused ? '工作流已暂停' : '暂停只阻止新任务领取,运行中任务不受影响'} · ${date(data.generatedAt)}
+
+
+ ${!data.available ? '
核心 Workflow 服务尚未挂接:当前页面只展示已持久化的镜像状态,控制操作会安全返回 503。
' : ''} +

Gitea 连接与工作区配置

+ + + + + +
+
+
运行中${Number(summary.running || 0)}
队列${Number(summary.queued || 0)}
仓库${Number(summary.repositories || 0)}
脏目录 / 阻塞${Number(summary.blocked || 0)}
+

仓库与工作区

${repositories.length ? repositories.map((repo) => ``).join('') : ''}
仓库状态工作区脏目录原因队列操作
${escapeHtml(repo.owner ? `${repo.owner}/${repo.name}` : repo.repoKey)}
${escapeHtml(repo.repoKey)}
${repo.enabled ? '启用' : '停用'}${escapeHtml(repo.workspacePath || '—')}${repo.dirty ? escapeHtml(repo.dirtyReason || '检测到未提交修改或冲突') : '—'}${Number(repo.queuedCount || 0)}
暂无仓库记录
+

任务队列与 Turn

${tasks.length ? tasks.map((task) => ``).join('') : ''}
任务 / 资源状态Turn脏目录原因更新时间操作
${escapeHtml(task.taskId || '—')}
${escapeHtml(task.resourceKey || task.repoKey || '—')}
${escapeHtml(stateLabel(task))}${task.errorMessage ? `
${escapeHtml(task.errorMessage)}` : ''}
${escapeHtml(task.turnId || '—')}
${escapeHtml(task.turnState || '—')}
${escapeHtml(task.dirtyReason || '—')}${date(task.updatedAt || task.turnUpdatedAt || task.createdAt)}${task.state === 'queued' || task.state === 'waiting_user' ? `` : ''}${task.state === 'running' || task.state === 'preparing' || task.state === 'aborting' ? `` : ''}
暂无任务记录
+

运行日志

${logs.length ? logs.slice().reverse().map((entry) => `
${date(entry.timestamp)}${escapeHtml(entry.level)}${escapeHtml(entry.message || entry.event || '—')}
`).join('') : '
暂无日志
'}
+

操作记录

${audits.length ? audits.slice().reverse().map((entry) => ``).join('') : ''}
时间动作任务 / 仓库
${date(entry.timestamp)}${escapeHtml(actionLabel(entry.action))}${escapeHtml(entry.taskId || entry.repoKey || '—')}
暂无记录
`; + root.querySelectorAll('[data-action], [data-repo-action], [data-task-action], [data-config-save]').forEach((button) => button.addEventListener('click', button.dataset.configSave !== undefined ? saveConfig : onAction)); + } + + async function load() { + if (loading) return; + loading = true; + status.textContent = '正在刷新工作流状态…'; + try { + const [nextOverview, config] = await Promise.all([api('/api/gitea-workflow/overview'), api('/api/gitea-workflow/config')]); + overview = { ...nextOverview, settings: config.settings || {} }; + render(); + status.textContent = overview.available ? '状态已更新' : '核心服务未挂接,显示镜像状态'; + } catch (error) { + root.innerHTML = `
加载失败:${escapeHtml(error.message)}
`; + status.textContent = '加载失败'; + } finally { loading = false; } + } + + async function saveConfig(event) { + const button = event.currentTarget; + button.disabled = true; + try { + await api('/api/gitea-workflow/config', { method: 'PUT', body: JSON.stringify({ + ...systemAuditFields(), + host: $('#gitea-config-host')?.value || '', botLogin: $('#gitea-config-bot-login')?.value || '', + workspaceRoot: $('#gitea-config-workspace')?.value || '', defaultBranch: $('#gitea-config-branch')?.value || '', + botToken: $('#gitea-config-bot-token')?.value || '', + }) }); + await load(); + } catch (error) { window.alert(`配置保存失败:${error.message}`); } finally { button.disabled = false; } + } + + async function onAction(event) { + const button = event.currentTarget; + button.disabled = true; + try { + let path; + let method = 'POST'; + if (button.dataset.action) path = `/api/gitea-workflow/control/${button.dataset.action}`; + if (button.dataset.repoAction) path = `/api/gitea-workflow/repos/${encodeURIComponent(button.dataset.repoKey)}/${button.dataset.repoAction}`; + if (button.dataset.taskAction) path = `/api/gitea-workflow/tasks/${encodeURIComponent(button.dataset.taskId)}/${button.dataset.taskAction}`; + await api(path, { method, body: JSON.stringify({ + ...systemAuditFields(window.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`), + }) }); + await load(); + } catch (error) { + window.alert(`操作未执行:${error.message}`); + button.disabled = false; + } + } + + function open() { + panel.hidden = false; panel.setAttribute('aria-hidden', 'false'); openButton.setAttribute('aria-expanded', 'true'); load(); + } + function close() { panel.hidden = true; panel.setAttribute('aria-hidden', 'true'); openButton.setAttribute('aria-expanded', 'false'); } + openButton.addEventListener('click', open); + closeButton?.addEventListener('click', close); + refreshButton?.addEventListener('click', load); +})(); diff --git a/public/index.html b/public/index.html index bdec15d..553e4a5 100644 --- a/public/index.html +++ b/public/index.html @@ -26,6 +26,7 @@ + @@ -78,6 +79,9 @@ +
CC-Web @@ -129,6 +133,18 @@
+ + @@ -401,6 +417,7 @@ + diff --git a/public/style.css b/public/style.css index 61fd990..77be565 100644 --- a/public/style.css +++ b/public/style.css @@ -1505,6 +1505,16 @@ body.session-loading-active { border-radius: 4px 0 0 0; opacity: 0.88; } +.session-item.gitea-session:not(.pinned)::before { + content: ''; + position: absolute; + left: 4px; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 999px; + background: #3f8f73; +} .session-item-main { flex: 1; min-width: 0; @@ -1520,6 +1530,20 @@ body.session-loading-active { font-size: 14px; color: var(--text-primary); } +.session-item-source-badge { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid rgba(63, 143, 115, 0.34); + border-radius: 5px; + background: rgba(63, 143, 115, 0.12); + color: #2d755e; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 1.2; +} .session-item.active .session-item-title { color: var(--accent); font-weight: 500; } .session-item-pin-badge { flex-shrink: 0; @@ -2799,6 +2823,10 @@ html[data-theme='wasteland'] .advanced-search-panel { flex-shrink: 0; } .msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; } +.msg.gitea-message .msg-avatar { + background: #3f8f73; + color: #fff; +} .msg.assistant .msg-avatar { background: var(--success); color: #fff; } .msg-avatar svg { display: block; flex-shrink: 0; } /* Claude avatar: transparent bg, fixed-color pixel crab */ @@ -2991,6 +3019,31 @@ html[data-theme='wasteland'] .advanced-search-panel { border-bottom-right-radius: 4px; padding-right: 42px; } +.msg.gitea-message .msg-bubble { + border: 1px solid rgba(63, 143, 115, 0.36); + box-shadow: 0 8px 18px rgba(63, 143, 115, 0.12); +} +.gitea-message-label { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 5px; + margin: -2px 0 7px; + padding: 2px 7px; + border: 1px solid rgba(63, 143, 115, 0.34); + border-radius: 5px; + background: rgba(63, 143, 115, 0.16); + color: rgba(255, 255, 255, 0.92); + font-size: 10px; + font-weight: 800; + line-height: 1.35; + letter-spacing: 0.03em; +} +.gitea-message-label::before { + content: '↗'; + font-size: 10px; + line-height: 1; +} .msg.user.goal-message .msg-bubble { display: flex; flex-direction: column; @@ -6403,6 +6456,101 @@ html[data-theme='coolvibe'] .settings-back:hover { .codex-approval-deny-btn { color: var(--danger); } +.codex-elicitation-panel { + max-width: 620px; +} +.codex-elicitation-body { + display: flex; + flex-direction: column; + gap: 12px; + max-height: min(68vh, 680px); + overflow: auto; +} +.codex-elicitation-message { + color: var(--text-primary); + font-size: 15px; + line-height: 1.5; + white-space: pre-wrap; +} +.codex-elicitation-mode { + align-self: flex-start; + padding: 3px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.codex-elicitation-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.codex-elicitation-label { + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} +.codex-elicitation-desc { + color: var(--text-muted); + font-size: 12px; + line-height: 1.4; +} +.codex-elicitation-control { + display: flex; + flex-direction: column; + gap: 7px; +} +.codex-elicitation-input { + box-sizing: border-box; + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; + outline: none; +} +.codex-elicitation-input:focus { + border-color: var(--accent); +} +textarea.codex-elicitation-input { + min-height: 90px; + resize: vertical; +} +.codex-elicitation-check { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + font-size: 13px; +} +.codex-elicitation-url-copy { + color: var(--text-secondary); + line-height: 1.5; +} +.codex-elicitation-url { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 9px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--accent); + text-decoration: none; + word-break: break-all; +} +.codex-elicitation-url:hover { + background: var(--bg-secondary); +} +.codex-elicitation-footer { + flex-wrap: wrap; +} +.codex-elicitation-decline { + color: var(--danger); +} .modal-quick-picks { display: flex; flex-wrap: wrap; diff --git a/scripts/build-single-exe.js b/scripts/build-single-exe.js index a542967..326cd45 100644 --- a/scripts/build-single-exe.js +++ b/scripts/build-single-exe.js @@ -117,6 +117,7 @@ ${runCommand} ## 目录说明 - \`public/\`:前端静态资源 +- \`bin/gitea-mcp\`:Gitea Workflow 使用的官方 MCP 二进制 - \`config/\`:运行时配置,首次启动会写入登录和通知配置 - \`sessions/\`:会话数据 - \`logs/\`:运行日志 @@ -128,6 +129,8 @@ ${runCommand} function copyRuntimeAssets(projectRoot, releaseDir, target, binaryName) { copyDirIfExists(path.join(projectRoot, 'public'), path.join(releaseDir, 'public')); + // Gitea MCP 是 cc-web 工作流运行时的一部分,随源码/单文件发布目录一起交付。 + copyDirIfExists(path.join(projectRoot, 'bin'), path.join(releaseDir, 'bin')); for (const file of ['.env.example', 'README.md', 'README.en.md', 'CHANGELOG.md', 'package.json']) { copyFileIfExists(path.join(projectRoot, file), path.join(releaseDir, file)); diff --git a/scripts/gitea-mcp-probe-unit.js b/scripts/gitea-mcp-probe-unit.js new file mode 100644 index 0000000..00a2282 --- /dev/null +++ b/scripts/gitea-mcp-probe-unit.js @@ -0,0 +1,86 @@ +'use strict'; + +/** gitea-mcp stdio 预检离线回归。 */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const process = require('node:process'); +const { + DEFAULT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + probeGiteaMcp, +} = require('../lib/gitea-mcp-probe'); + +const fixture = path.join(__dirname, '..', 'fixtures', 'gitea-workflow', 'mock-gitea-mcp.js'); + +async function expectCode(promise, code) { + await assert.rejects(promise, (error) => { + assert.equal(error.code, code); + return true; + }); +} + +async function main() { + assert.equal(DEFAULT_TIMEOUT_MS, 8000); + assert.equal(MAX_TIMEOUT_MS, 30000); + + const success = await probeGiteaMcp({ + command: process.execPath, + args: [fixture], + env: { GITEA_MCP_FIXTURE_MODE: 'success', GITEA_ACCESS_TOKEN: 'fixture-token' }, + timeoutMs: 1000, + }); + assert.equal(success.ok, true); + assert.equal(success.protocolVersion, '2024-11-05'); + + await expectCode(probeGiteaMcp({ + command: process.execPath, + args: [fixture], + env: { GITEA_MCP_FIXTURE_MODE: 'error' }, + timeoutMs: 1000, + }), 'gitea_mcp_handshake_failed'); + + await expectCode(probeGiteaMcp({ + command: process.execPath, + args: [fixture], + env: { GITEA_MCP_FIXTURE_MODE: 'exit' }, + timeoutMs: 1000, + }), 'gitea_mcp_start_failed'); + + await expectCode(probeGiteaMcp({ + command: process.execPath, + args: [fixture], + env: { GITEA_MCP_FIXTURE_MODE: 'timeout' }, + timeoutMs: 250, + }), 'gitea_mcp_handshake_timeout'); + + await expectCode(probeGiteaMcp({ + command: '/definitely/missing/gitea-mcp', + timeoutMs: 1000, + }), 'gitea_mcp_spawn_failed'); + + // 协议守门:失败仍交给统一的队列终态回执,线程级注入和“禁止自动安装”不回退。 + const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); + const probeSource = fs.readFileSync(path.join(__dirname, '..', 'lib', 'gitea-mcp-probe.js'), 'utf8'); + const buildSource = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-single-exe.js'), 'utf8'); + assert.match(serverSource, /await probeGiteaMcp\(\{/); + assert.match(serverSource, /code: 'gitea_mcp_not_found'/); + assert.match(serverSource, /path\.join\(APP_DIR, 'bin', bundledName\)/); + assert.match(probeSource, /gitea_mcp_start_failed/); + assert.match(probeSource, /gitea_mcp_handshake_failed/); + assert.match(probeSource, /gitea_mcp_handshake_timeout/); + assert.match(serverSource, /failed: \(task\) => `任务失败:/); + assert.match(serverSource, /failed_reply: \(task\) => `回帖失败:/); + assert.match(serverSource, /statusComments/); + assert.match(serverSource, /config\['mcp_servers\.gitea'\]/); + assert.match(buildSource, /copyDirIfExists\(path\.join\(projectRoot, 'bin'\), path\.join\(releaseDir, 'bin'\)\)/); + assert.doesNotMatch(serverSource, /npm\s+(?:install| i)\b|curl\s+[^\n]*(?:gitea-mcp|github)/i); + + console.log('Gitea MCP probe unit checks passed.'); +} + +main().catch((error) => { + console.error(error.stack || error.message || error); + process.exitCode = 1; +}); diff --git a/scripts/gitea-webhook-regression.js b/scripts/gitea-webhook-regression.js new file mode 100644 index 0000000..aaf6304 --- /dev/null +++ b/scripts/gitea-webhook-regression.js @@ -0,0 +1,319 @@ +#!/usr/bin/env node + +/* + * Gitea Webhook 工作流的离线协议回归。 + * 该脚本不加载 server.js,不访问网络,也不写业务 Store;实现落地时可把同一 + * 组 fixtures 接入 HTTP/Store/MCP 集成测试。 + */ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const FIXTURE_DIR = path.join(ROOT, 'fixtures', 'gitea-workflow'); +const TEST_SECRET = 'fixture-only-webhook-secret'; +const BOT_LOGIN = 'ccweb-bot'; + +function loadFixture(name) { + return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8')); +} + +function rawBody(payload) { + return JSON.stringify(payload); +} + +function signBody(body, secret = TEST_SECRET) { + return crypto.createHmac('sha256', secret).update(body).digest('hex'); +} + +function verifySignature(body, header, secret = TEST_SECRET) { + const expected = Buffer.from(signBody(body, secret), 'hex'); + const suppliedText = String(header || '').replace(/^sha256=/i, ''); + const supplied = /^[0-9a-f]{64}$/i.test(suppliedText) + ? Buffer.from(suppliedText, 'hex') + : Buffer.alloc(expected.length); + return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected); +} + +function normalizeEvent(fixture) { + const headers = Object.fromEntries(Object.entries(fixture.headers || {}) + .map(([key, value]) => [key.toLowerCase(), value])); + const type = headers['x-gitea-event']; + if (!['issue_comment', 'pull_request_comment'].includes(type)) { + return {accepted: false, reason: 'unsupported_event'}; + } + const payload = fixture.payload || {}; + const repository = payload.repository || {}; + const comment = payload.comment || {}; + const sender = payload.sender || comment.user || {}; + const pullRequest = payload.pull_request; + const issue = payload.issue; + const resource = type === 'pull_request_comment' || pullRequest + ? { + kind: 'pull_request', + number: Number(pullRequest && pullRequest.number), + base: pullRequest && pullRequest.base && pullRequest.base.ref, + head: pullRequest && pullRequest.head && pullRequest.head.ref, + } + : {kind: 'issue', number: Number(issue && issue.number)}; + return { + accepted: true, + instanceId: 'default', + deliveryId: headers['x-gitea-delivery'], + eventType: type, + repo: { + owner: String(repository.owner || ''), + name: String(repository.name || ''), + cloneUrl: repository.clone_url || null, + defaultBranch: repository.default_branch || null, + }, + resource, + comment: { + id: Number(comment.id), + body: String(comment.body || ''), + createdAt: comment.created_at || null, + }, + actor: {login: String(sender.login || ''), isBot: Boolean(sender.is_bot || sender.type === 'Bot')}, + }; +} + +function filterEvent(event, {botLogin = BOT_LOGIN, repositoryStatus = 'active'} = {}) { + if (!event.accepted) return event; + if (event.actor.login.toLowerCase() === botLogin.toLowerCase() || event.actor.isBot) { + return {...event, accepted: false, reason: 'bot_self_comment'}; + } + if (repositoryStatus !== 'active') { + return {...event, accepted: false, reason: 'repository_disabled'}; + } + if (!/(^|[\s(])@ccweb-bot\b/i.test(event.comment.body)) { + return {...event, accepted: false, reason: 'missing_mention'}; + } + const instruction = event.comment.body.replace(/(^|[\s(])@ccweb-bot\b/i, '$1').trim(); + return {...event, instruction}; +} + +class DeliveryQueue { + constructor() { + this.deliveries = new Map(); + this.tasks = []; + } + + accept(event) { + const deliveryKey = `${event.instanceId}:${event.deliveryId}`; + if (this.deliveries.has(deliveryKey)) { + return {...this.deliveries.get(deliveryKey), status: 'duplicate'}; + } + const record = {deliveryKey, status: event.accepted ? 'accepted' : 'ignored'}; + this.deliveries.set(deliveryKey, record); + if (!event.accepted) return record; + const task = { + taskId: `task-${this.tasks.length + 1}`, + deliveryKey, + repoKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}`, + resourceKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}:${event.resource.kind}:${event.resource.number}`, + state: 'queued', + }; + this.tasks.push(task); + record.taskId = task.taskId; + return record; + } +} + +function recoverTasks(snapshot) { + return snapshot.tasks.map((task) => { + if (['succeeded', 'failed', 'cancelled', 'aborted', 'waiting_user'].includes(task.state)) { + return {...task}; + } + if (task.state === 'running' || task.state === 'preparing' || task.state === 'aborting') { + return task.attempt >= 1 + ? {...task, state: 'failed', errorCode: 'restart_retry_exhausted'} + : {...task, state: 'retry_wait', attempt: task.attempt + 1, errorCode: 'interrupted_by_restart'}; + } + return {...task}; + }); +} + +function runReplyCompensation(fixture) { + const events = []; + if (fixture.mcpAccepted && !fixture.mcpCommentVisible) { + events.push('reply_retry'); + if (!fixture.retryCommentVisible && fixture.restFallbackVisible) { + events.push('rest_fallback'); + return {state: 'succeeded_with_rest_fallback', events}; + } + if (fixture.retryCommentVisible) return {state: 'succeeded', events}; + return {state: 'failed_reply', events}; + } + return {state: fixture.mcpCommentVisible ? 'succeeded' : 'failed_reply', events}; +} + +function buildThreadConfig(fixture) { + return { + cwd: fixture.cwd, + mcp_servers: { + gitea: { + command: fixture.mcpServer.command, + args: fixture.mcpServer.args, + env: fixture.mcpServer.env, + }, + }, + collaborationMode: fixture.collaborationMode, + }; +} + +function isWorkspacePathSafe(candidate, root) { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`); +} + +const tests = []; +function test(name, fn) { + tests.push({name, fn}); +} + +test('合法 Issue/PR 评论规范化并提取指令', () => { + const issue = loadFixture('valid-issue-comment.json'); + const issueEvent = filterEvent(normalizeEvent(issue)); + assert.equal(issueEvent.accepted, true); + assert.equal(issueEvent.resource.kind, 'issue'); + assert.equal(issueEvent.instruction, issue.expected.instruction); + + const pr = loadFixture('valid-pr-comment.json'); + const prEvent = filterEvent(normalizeEvent(pr)); + assert.equal(prEvent.accepted, true); + assert.equal(prEvent.resource.kind, 'pull_request'); + assert.equal(prEvent.resource.base, pr.expected.base); + assert.equal(prEvent.resource.head, pr.expected.head); +}); + +test('HMAC 验签使用 raw body,错误签名不产生任务', () => { + const fixture = loadFixture('valid-issue-comment.json'); + const body = rawBody(fixture.payload); + const signature = signBody(body); + assert.equal(verifySignature(body, signature), true); + assert.equal(verifySignature(`${body} `, signature), false); + assert.equal(verifySignature(body, ''), false); + const queue = new DeliveryQueue(); + assert.equal(verifySignature(body, '00'.repeat(32)), false); + assert.equal(queue.tasks.length, 0); +}); + +test('事件过滤阻止 Bot 自评论、无 mention、非评论和停用仓库', () => { + const bot = filterEvent(normalizeEvent(loadFixture('bot-self-comment.json'))); + assert.equal(bot.accepted, false); + assert.equal(bot.reason, 'bot_self_comment'); + for (const fixture of loadFixture('ignored-events.json')) { + const result = filterEvent(normalizeEvent(fixture)); + assert.equal(result.accepted, false, fixture.name); + assert.equal(result.reason, fixture.expected.reason, fixture.name); + } + const valid = filterEvent(normalizeEvent(loadFixture('valid-issue-comment.json')), {repositoryStatus: 'disabled'}); + assert.equal(valid.reason, 'repository_disabled'); +}); + +test('deliveryKey 幂等,重复 Webhook 不增加 task', () => { + const fixture = loadFixture('valid-issue-comment.json'); + const event = filterEvent(normalizeEvent(fixture)); + const queue = new DeliveryQueue(); + const first = queue.accept(event); + const second = queue.accept(event); + assert.equal(first.status, 'accepted'); + assert.equal(second.status, 'duplicate'); + assert.equal(queue.tasks.length, 1); + assert.equal(first.taskId, second.taskId); +}); + +test('重启恢复只重试中断任务一次,终态和 waiting_user 不重跑', () => { + const snapshot = loadFixture('queue-recovery.json'); + const recovered = recoverTasks(snapshot); + const byId = new Map(recovered.map((task) => [task.taskId, task])); + assert.equal(byId.get('task-queued').state, 'queued'); + assert.equal(byId.get('task-running').state, 'retry_wait'); + assert.equal(byId.get('task-running').errorCode, 'interrupted_by_restart'); + assert.equal(byId.get('task-running').attempt, 1); + assert.equal(byId.get('task-exhausted').state, 'failed'); + assert.equal(byId.get('task-waiting').state, 'waiting_user'); + assert.equal(byId.get('task-terminal').state, 'succeeded'); +}); + +test('队列恢复遵守同仓库锁', () => { + const active = [ + {repoKey: 'default:acme/widget', state: 'running'}, + {repoKey: 'default:acme/other', state: 'preparing'}, + ]; + assert.equal(active.filter((task) => task.repoKey === 'default:acme/widget' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1); + assert.equal(active.filter((task) => task.repoKey === 'default:acme/other' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1); + const queued = {repoKey: 'default:acme/widget', state: 'queued'}; + assert.equal(queued.state, 'queued'); +}); + +test('回帖缺失只补发一次,失败后 REST 兜底并区分终态', () => { + const result = runReplyCompensation(loadFixture('reply-failure.json')); + assert.equal(result.state, 'succeeded_with_rest_fallback'); + assert.deepEqual(result.events, ['reply_retry', 'rest_fallback']); + assert.equal(result.events.filter((event) => event === 'reply_retry').length, 1); +}); + +test('回执策略只发起始已收到,成功不再发送中间状态', () => { + const source = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); + assert.match(source, /postGiteaWorkflowStatus\(task, '已收到'\)/); + for (const status of ['已接收并排队', '运行中', '等待用户补充信息', '已完成,最终回帖已确认', '已完成(REST 兜底回帖)', '回帖状态未知']) { + const callPattern = new RegExp( + `postGiteaWorkflowStatus\\([^\\n;]*['"]${status}['"]`, + ); + assert.equal(callPattern.test(source), false, `不应发送中间状态:${status}`); + } + const settleStart = source.indexOf('async function settleGiteaWorkflowTurn'); + const runnerStart = source.indexOf('giteaWorkflowService.setRunner'); + const settleSource = source.slice(settleStart, runnerStart); + assert.doesNotMatch(settleSource, /postGiteaWorkflowStatus\(/, 'MCP 成功、等待用户和回帖重试过程不应再发状态评论'); + assert.match(source, /commandExistsForMcp\(giteaMcpCommand\)/, '启动前必须检查 gitea-mcp 命令是否存在'); + assert.match(source, /code: 'gitea_mcp_not_found'/, '缺失 MCP 命令必须使用稳定错误码快速失败'); + assert.match(source, /failed_reply: \(task\) => `回帖失败:/); +}); + +test('MCP 线程配置按线程注入且不重复传顶层 model/effort', () => { + const config = buildThreadConfig(loadFixture('mcp-thread-config.json')); + assert.equal(config.mcp_servers.gitea.command, 'gitea-mcp'); + assert.deepEqual(config.mcp_servers.gitea.args.slice(0, 2), ['-t', 'stdio']); + assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.startsWith('secret-ref:'), true); + assert.equal(config.model, undefined); + assert.equal(config.effort, undefined); + assert.equal(config.collaborationMode.settings.model, 'configured-model'); + assert.equal(config.collaborationMode.settings.reasoning_effort, 'medium'); +}); + +test('安全边界:路径、凭据和不可信正文不越权', () => { + const fixtureText = fs.readdirSync(FIXTURE_DIR) + .map((name) => fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8')) + .join('\n'); + assert.equal(fixtureText.includes('fixture-only-webhook-secret'), false); + assert.equal(fixtureText.includes('Authorization:'), false); + assert.equal(fixtureText.includes('..\\'), false); + const config = buildThreadConfig(loadFixture('mcp-thread-config.json')); + assert.equal(config.cwd.startsWith('/var/lib/ccweb/workspaces/'), true); + assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.includes('fixture-only'), false); + assert.equal(isWorkspacePathSafe(config.cwd, '/var/lib/ccweb/workspaces'), true); + assert.equal(isWorkspacePathSafe('/var/lib/ccweb/workspaces/../secrets', '/var/lib/ccweb/workspaces'), false); + assert.equal(isWorkspacePathSafe('/tmp/ccweb-workspace', '/var/lib/ccweb/workspaces'), false); +}); + +let failures = 0; +for (const current of tests) { + try { + current.fn(); + process.stdout.write(`PASS ${current.name}\n`); + } catch (error) { + failures += 1; + process.stderr.write(`FAIL ${current.name}: ${error.message}\n`); + } +} + +if (failures > 0) { + process.stderr.write(`\n${failures}/${tests.length} 场景失败\n`); + process.exitCode = 1; +} else { + process.stdout.write(`\nGitea Webhook regression passed: ${tests.length} 场景\n`); +} diff --git a/scripts/gitea-webhook-workspace-unit.js b/scripts/gitea-webhook-workspace-unit.js new file mode 100644 index 0000000..aeb3a34 --- /dev/null +++ b/scripts/gitea-webhook-workspace-unit.js @@ -0,0 +1,243 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { + DeliveryStore, + createGiteaWebhookReceiver, + createGiteaWebhookRoute, + normalizeWebhookEvent, + parseBotMention, + verifyWebhookSignature, +} = require('../lib/gitea-webhook'); +const { buildWorkflowMarker } = require('../lib/gitea-workflow-codex'); +const { + BlockedWorkspaceError, + createGiteaWorkspaceManager, + createRepositoryLockManager, + normalizeRepository, + withTempGitCredentials, +} = require('../lib/gitea-workspace'); + +function sign(body, secret) { + return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`; +} + +function payloadFixture(overrides = {}) { + return { + action: 'created', + repository: { + owner: { login: 'alice' }, + name: 'demo', + full_name: 'alice/demo', + clone_url: 'https://gitea.example/alice/demo.git', + default_branch: 'main', + }, + issue: { number: 7, title: '修复问题' }, + comment: { + id: 19, + body: '@ccweb-bot 请检查这个问题', + user: { id: 11, login: 'alice' }, + }, + sender: { id: 11, login: 'alice' }, + ...overrides, + }; +} + +async function main() { + const secret = 'webhook-secret'; + const body = Buffer.from(JSON.stringify(payloadFixture())); + assert.equal(verifyWebhookSignature(body, sign(body, secret), secret), true); + assert.equal(verifyWebhookSignature(body, sign(body, secret).slice(7), secret), true); + assert.equal(verifyWebhookSignature(Buffer.from(`${body}\n`), sign(body, secret), secret), false); + assert.equal(parseBotMention('@ccweb-bot 做事').instruction, '做事'); + assert.equal(parseBotMention('提及 @ccweb-bot-extra'), null); + + const normalized = normalizeWebhookEvent({ + payload: payloadFixture(), + headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1' }, + instanceId: 'default', + botIdentity: { login: 'ccweb-bot' }, + }); + assert.equal(normalized.ignored, false); + assert.equal(normalized.repository.fullName, 'alice/demo'); + assert.equal(normalized.resource.key, 'issue:7'); + assert.equal(normalized.mention.instruction, '请检查这个问题'); + assert.equal(normalizeWebhookEvent({ + payload: payloadFixture({ comment: { body: '@ccweb-bot 状态', user: { login: 'ccweb-bot' } }, sender: { login: 'ccweb-bot' } }), + headers: { 'x-gitea-event': 'issue_comment' }, + botIdentity: { login: 'ccweb-bot' }, + }).reason, 'bot_self_comment'); + assert.equal(normalizeWebhookEvent({ + payload: payloadFixture({ comment: { body: '普通评论', user: { login: 'alice' } } }), + headers: { 'x-gitea-event': 'issue_comment' }, + botIdentity: { login: 'ccweb-bot' }, + }).reason, 'missing_mention'); + assert.equal(normalizeWebhookEvent({ + payload: payloadFixture({ comment: { body: `@ccweb-bot 状态\n${buildWorkflowMarker({ taskId: 't', turnId: 'u', resourceKey: 'r' })}`, user: {} }, sender: {} }), + headers: { 'x-gitea-event': 'issue_comment' }, + botIdentity: { login: 'ccweb-bot' }, + }).reason, 'workflow_marker_comment'); + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-webhook-unit-')); + try { + const deliveryFile = path.join(tempRoot, 'deliveries.json'); + const store = new DeliveryStore({ filePath: deliveryFile }); + const tasks = []; + const receiver = createGiteaWebhookReceiver({ + secret, + instanceId: 'default', + deliveryStore: store, + onTask: async (task) => tasks.push(task), + }); + const accepted = await receiver.processWebhook({ + headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) }, + rawBody: body, + }); + assert.equal(accepted.statusCode, 202); + assert.match(accepted.payload.taskId, /^gitea-task-/); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(tasks.length, 1); + const duplicate = await receiver.processWebhook({ + headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) }, + rawBody: body, + }); + assert.equal(duplicate.statusCode, 200); + assert.equal(duplicate.payload.duplicate, true); + const invalid = await receiver.processWebhook({ + headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-2', 'x-gitea-signature': 'sha256=bad' }, + rawBody: body, + }); + assert.equal(invalid.statusCode, 401); + assert.equal(store.has('default:d-2'), false); + const ignored = await receiver.processWebhook({ + headers: { 'x-gitea-event': 'push', 'x-gitea-delivery': 'd-3', 'x-gitea-signature': sign(body, secret) }, + rawBody: body, + }); + assert.equal(ignored.statusCode, 200); + assert.equal(ignored.payload.ignored, true); + const reloaded = new DeliveryStore({ filePath: deliveryFile }); + assert.equal(reloaded.has('default:d-1'), true); + + const route = createGiteaWebhookRoute(receiver); + const request = new EventEmitter(); + request.method = 'POST'; + request.url = '/api/gitea/webhook'; + request.headers = { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-route', 'x-gitea-signature': sign(body, secret) }; + const responseDone = new Promise((resolve) => { + const response = { + headersSent: false, + writeHead(statusCode) { this.statusCode = statusCode; this.headersSent = true; }, + end(value) { this.body = JSON.parse(value); resolve(this); }, + }; + assert.equal(route(request, response, new URL('http://localhost/api/gitea/webhook')), true); + request.emit('data', body); + request.emit('end'); + }); + const response = await responseDone; + assert.equal(response.statusCode, 202); + assert.equal(response.body.state, 'queued'); + assert.equal(route({ method: 'GET', url: '/api/gitea/webhook', headers: {} }, {}, '/api/gitea/webhook'), false); + + receiver.updateConfig({ botLogin: 'renamed-bot' }); + const renamedBody = Buffer.from(JSON.stringify(payloadFixture({ + comment: { id: 20, body: '@renamed-bot 继续处理', user: { id: 11, login: 'alice' } }, + }))); + const renamed = await receiver.processWebhook({ + headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-renamed', 'x-gitea-signature': sign(renamedBody, secret) }, + rawBody: renamedBody, + }); + assert.equal(renamed.statusCode, 202); + assert.equal(renamed.payload.status, 'accepted'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workspace-unit-')); + try { + assert.throws(() => normalizeRepository({ owner: '../bad', name: 'demo', cloneUrl: 'https://gitea.example/a/demo.git' }), /owner/); + assert.throws(() => normalizeRepository({ owner: 'alice', name: 'demo', cloneUrl: 'http://gitea.example/a/demo.git' }), /HTTPS/); + const calls = []; + const manager = createGiteaWorkspaceManager({ + workspaceRoot, + instanceId: 'default', + botUsername: 'ccweb-bot', + gitRunner: async ({ args, cwd, env }) => { + calls.push({ args, cwd, env }); + if (args[0] === 'clone') { + fs.mkdirSync(path.join(args[args.length - 1], '.git'), { recursive: true }); + } + if (args[0] === 'status') return { code: 0, stdout: '', stderr: '' }; + return { code: 0, stdout: '', stderr: '' }; + }, + }); + const repository = { owner: 'alice', name: 'demo', cloneUrl: 'https://gitea.example/alice/demo.git', defaultBranch: 'main' }; + const first = await manager.ensureRepository(repository, { token: 'token-value' }); + assert.equal(first.status, 'ready'); + assert.equal(calls[0].args[0], 'clone'); + assert.deepEqual(calls[0].args.slice(0, 7), ['clone', '--origin', 'origin', '--depth', '1', '--single-branch', '--branch']); + assert.equal(calls[0].args[7], 'main'); + assert.equal(calls[0].args.includes('token-value'), false); + assert.equal(calls[0].env.GIT_ASKPASS.endsWith('askpass.sh'), true); + await manager.ensureRepository(repository, { token: 'token-value' }); + assert.deepEqual(calls.slice(1).map((item) => item.args[0]), ['status', 'fetch', 'checkout', 'merge']); + + const dirtyManager = createGiteaWorkspaceManager({ + workspaceRoot: path.join(workspaceRoot, 'dirty'), + instanceId: 'default', + gitRunner: async ({ args }) => { + if (args[0] === 'clone') return { code: 0, stdout: '', stderr: '' }; + if (args[0] === 'status') return { code: 0, stdout: ' M tracked.txt\n', stderr: '' }; + return { code: 0, stdout: '', stderr: '' }; + }, + }); + const dirtyPath = dirtyManager.workspacePath(repository); + fs.mkdirSync(path.join(dirtyPath, '.git'), { recursive: true }); + await assert.rejects(() => dirtyManager.ensureRepository(repository, { token: 'token-value' }), (error) => { + assert(error instanceof BlockedWorkspaceError); + assert.equal(error.code, 'blocked_workspace'); + return true; + }); + const dirtyContinuation = await dirtyManager.ensureRepository(repository, { + token: 'token-value', allowDirty: true, dirtySessionKey: 'default:alice/demo:issue:7', + }); + assert.equal(dirtyContinuation.status, 'dirty_ready'); + assert.equal(dirtyContinuation.dirtySessionKey, 'default:alice/demo:issue:7'); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-lock-unit-')); + try { + const locks = createRepositoryLockManager({ lockRoot, retryMs: 5, waitTimeoutMs: 1000 }); + const order = []; + await Promise.all([ + locks.withLock('same-repo', async () => { + order.push('first-start'); + await new Promise((resolve) => setTimeout(resolve, 30)); + order.push('first-end'); + }), + locks.withLock('same-repo', async () => order.push('second')), + ]); + assert.deepEqual(order, ['first-start', 'first-end', 'second']); + } finally { + fs.rmSync(lockRoot, { recursive: true, force: true }); + } + + let seenEnv; + await withTempGitCredentials({ token: 'secret-token', username: 'bot' }, async (env) => { + seenEnv = env; + }); + assert.equal(seenEnv.CCWEB_GIT_TOKEN, 'secret-token'); + assert.equal(fs.existsSync(seenEnv.GIT_ASKPASS), false, '临时凭据脚本必须在命令完成后清理'); + console.log('Gitea webhook/workspace unit checks passed'); +} + +main().catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/gitea-workflow-codex-unit.js b/scripts/gitea-workflow-codex-unit.js new file mode 100644 index 0000000..9879f89 --- /dev/null +++ b/scripts/gitea-workflow-codex-unit.js @@ -0,0 +1,153 @@ +'use strict'; + +/** Gitea Workflow Codex/App 协议适配器离线单测。 */ +const assert = require('node:assert/strict'); +const { + buildGiteaMcpServerConfig, + buildGiteaThreadConfig, + buildGiteaMcpConfig, + extractTurnId, + extractThreadId, + buildWorkflowMarker, + parseWorkflowMarker, + encodeMarker, + isBotComment, + createGiteaWorkflowCodex, + WORKFLOW_STATES, +} = require('../lib/gitea-workflow-codex'); + +async function main() { + const mcp = buildGiteaMcpServerConfig({ + host: 'https://gitea.example/', + accessToken: 'secret-token', + }); + assert.deepEqual(mcp.args.slice(0, 4), ['-t', 'stdio', '-H', 'https://gitea.example']); + assert.equal(mcp.env.GITEA_ACCESS_TOKEN, 'secret-token'); + assert.equal(mcp.env.GITEA_HOST, 'https://gitea.example'); + assert.equal(buildGiteaMcpConfig({ gitea: { host: 'https://gitea.example', token: 't' } }).type, 'stdio'); + + const thread = buildGiteaThreadConfig({ + cwd: '/tmp/gitea-workspace', + host: 'https://gitea.example', + accessToken: 'secret-token', + }); + assert.equal(thread.config['mcp_servers.gitea'].type, 'stdio'); + assert.equal(thread.config['mcp_servers.gitea'].env.GITEA_ACCESS_TOKEN, 'secret-token'); + assert.equal(Object.hasOwn(thread.config, 'model'), false); + + assert.equal(extractTurnId({ params: { turn: { id: 'turn-1' } } }), 'turn-1'); + assert.equal(extractThreadId({ params: { thread: { id: 'thread-1' } } }), 'thread-1'); + const marker = buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:a/r:issue:1' }); + assert.deepEqual(parseWorkflowMarker(`正文\n${marker}`), { + version: '1', taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:a/r:issue:1', kind: 'final', + }); + assert.equal(parseWorkflowMarker(''), null); + assert.equal(encodeMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'r', state: 'fallback' }).includes('kind="fallback"'), true); + + const bot = { id: '42', login: 'ccweb-bot' }; + assert.equal(isBotComment({ user: { id: 42, login: 'renamed-bot' } }, bot), true); + assert.equal(isBotComment({ user: { login: 'CCWEB-BOT' } }, bot), true); + assert.equal(isBotComment({ user: { login: 'alice' } }, bot), false); + assert.equal( + createGiteaWorkflowCodex({ botIdentity: bot }).matchWorkflowReply( + { body: `已完成\n${buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-old', resourceKey: 'r' })}`, user: bot }, + { taskId: 'task-1', turnId: 'turn-new', turnIds: ['turn-new', 'turn-old'], resourceKey: 'r' }, + ), + true, + ); + + let nowTick = 0; + const retryCalls = []; + const restCalls = []; + let verifyCalls = 0; + const adapter = createGiteaWorkflowCodex({ + botIdentity: bot, + now: () => new Date(Date.UTC(2026, 7, 24, 0, 0, nowTick++)), + verifyReply: async ({ source, expected }) => { + verifyCalls += 1; + if (source === 'mcp' && verifyCalls === 1) return { status: 'missing' }; + if (source === 'mcp' && verifyCalls === 2) return { + comments: [{ + id: 99, + body: `已完成\n${buildWorkflowMarker(expected)}`, + user: { id: 42, login: 'ccweb-bot' }, + }], + }; + return { status: 'missing' }; + }, + startTurn: async ({ threadId, kind, hidden, prompt }) => { + retryCalls.push({ threadId, kind, hidden, prompt }); + return { turnId: 'turn-retry-1' }; + }, + sendRestReply: async (input) => { + restCalls.push(input); + return { confirmed: true, commentId: 100 }; + }, + }); + adapter.createTask({ taskId: 'task-1', sessionKey: 'default:r:issue:1', resourceKey: 'default:r:issue:1' }); + adapter.attachThread('task-1', 'thread-1'); + adapter.handleTurnStarted({ taskId: 'task-1', threadId: 'thread-1', turnId: 'turn-1' }); + const retry = await adapter.handleTurnCompleted({ taskId: 'task-1', turnId: 'turn-1', finalText: '最终答案' }); + assert.equal(retry.event, 'reply_retry_started'); + assert.equal(retryCalls.length, 1); + assert.equal(retryCalls[0].threadId, 'thread-1'); + assert.equal(retryCalls[0].kind, 'reply_retry'); + assert.equal(retryCalls[0].hidden, true); + const confirmed = await adapter.handleTurnCompleted({ taskId: 'task-1', turnId: 'turn-retry-1', finalText: '最终答案' }); + assert.equal(confirmed.event, 'reply_confirmed'); + assert.equal(confirmed.source, 'mcp'); + assert.equal(restCalls.length, 0); + + let unknownRetry = 0; + let unknownRest = 0; + const unknown = createGiteaWorkflowCodex({ + verifyReply: async () => ({ status: 'unknown' }), + startTurn: async () => { unknownRetry += 1; return { turnId: 'should-not-run' }; }, + sendRestReply: async () => { unknownRest += 1; return { confirmed: true }; }, + }); + unknown.createTask({ taskId: 'task-unknown', sessionKey: 's', resourceKey: 'r' }); + unknown.handleTurnStarted({ taskId: 'task-unknown', threadId: 'thread-u', turnId: 'turn-u' }); + const unknownResult = await unknown.handleTurnCompleted({ taskId: 'task-unknown', turnId: 'turn-u', finalText: 'x' }); + assert.equal(unknownResult.event, 'reply_verification_unknown'); + assert.equal(unknownRetry, 0); + assert.equal(unknownRest, 0); + assert.equal(unknown.getTask('task-unknown').state, WORKFLOW_STATES.FAILED_REPLY); + + const continuation = adapter.queueWaitingUserComment({ + taskId: 'task-1', + nextTaskId: 'task-2', + comment: { id: 7, body: '@ccweb-bot 请继续', user: { id: 9, login: 'alice' } }, + }); + assert.equal(continuation.threadId, 'thread-1'); + assert.equal(adapter.getTask('task-2').parentTaskId, 'task-1'); + + const waitingAdapter = createGiteaWorkflowCodex({ botIdentity: bot }); + waitingAdapter.createTask({ taskId: 'task-wait', sessionKey: 's-wait', resourceKey: 'r-wait', threadId: 'thread-wait' }); + waitingAdapter.handleTurnStarted({ taskId: 'task-wait', threadId: 'thread-wait', turnId: 'turn-wait' }); + const waiting = waitingAdapter.handleTurnEvent({ + taskId: 'task-wait', + notification: { method: 'item/tool/requestUserInput', params: { turnId: 'turn-wait', questions: [{ id: 'q1' }] } }, + }); + assert.equal(waiting.event, 'waiting_user'); + assert.equal(waitingAdapter.getTask('task-wait').state, WORKFLOW_STATES.WAITING_USER); + const waitingContinuation = waitingAdapter.queueWaitingUserComment({ + taskId: 'task-wait', nextTaskId: 'task-wait-2', + comment: { id: 10, body: '@ccweb-bot 答案是 main', user: { id: 9, login: 'alice' } }, + }); + assert.equal(waitingContinuation.threadId, 'thread-wait'); + assert.equal(adapter.queueWaitingUserComment({ + taskId: 'task-1', nextTaskId: 'task-bot', + comment: { id: 8, body: 'bot', user: { id: 42, login: 'ccweb-bot' } }, + }).ignored, true); + assert.equal(adapter.queueWaitingUserComment({ + taskId: 'task-1', nextTaskId: 'task-marker', + comment: { id: 9, body: `重复回执 ${buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:r:issue:1' })}`, user: { id: 9, login: 'alice' } }, + }).ignored, true); + + console.log('Gitea Workflow Codex unit checks passed.'); +} + +main().catch((error) => { + console.error(error.stack || error.message || error); + process.exitCode = 1; +}); diff --git a/scripts/gitea-workflow-core-unit.js b/scripts/gitea-workflow-core-unit.js new file mode 100644 index 0000000..82da4ac --- /dev/null +++ b/scripts/gitea-workflow-core-unit.js @@ -0,0 +1,176 @@ +'use strict'; + +/** Gitea Workflow 核心领域模块单元测试,可直接执行:node scripts/gitea-workflow-core-unit.js */ + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const domain = require('../lib/gitea-workflow-domain'); +const { createGiteaWorkflowStore } = require('../lib/gitea-workflow-store'); +const { createGiteaWorkflowQueue } = require('../lib/gitea-workflow-queue'); +const { createGiteaWorkflowService, buildGiteaMcpConfig } = require('../lib/gitea-workflow-service'); + +function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function sign(body, secret) { return crypto.createHmac('sha256', secret).update(body).digest('hex'); } +function tempState() { return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-core-')), 'state.json'); } +function payload(body, number = 7, login = 'alice') { + return { + action: 'created', + repository: { + owner: { login: 'acme' }, name: 'demo', full_name: 'acme/demo', + clone_url: 'https://gitea.example/acme/demo.git', default_branch: 'main', + }, + issue: { number, title: '测试 Issue' }, + comment: { id: `${number}-comment`, body, user: { login } }, + sender: { login }, + }; +} + +async function run() { + const statePath = tempState(); + const secret = 'unit-secret'; + const results = []; + const check = (name, fn) => Promise.resolve().then(fn).then(() => results.push(`PASS ${name}`)); + + await check('配置、标识与敏感字段脱敏', () => { + const config = domain.normalizeConfig({ instanceId: 'default', gitea: { token: 'top-secret', webhookSecret: secret } }); + assert.equal(config.maxConcurrency, undefined); + assert.equal(domain.sessionKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7'); + assert.equal(domain.resourceKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7'); + const publicConfig = domain.publicConfig(config); + assert.notEqual(publicConfig.gitea.token, 'top-secret'); + assert.match(publicConfig.gitea.token, /^sha256:/); + }); + + await check('状态机阻止非法迁移并允许正常迁移', () => { + const task = domain.createTaskRecord({ taskId: 't-state', state: 'received' }); + const queued = domain.transitionTask(task, 'queued'); + assert.equal(queued.state, 'queued'); + assert.throws(() => domain.transitionTask(queued, 'succeeded'), /不可从/); + assert.equal(domain.transitionTask(queued, 'preparing').state, 'preparing'); + }); + + await check('Webhook 验签、mention/Bot 过滤、delivery 去重与任务入队', async () => { + const service = createGiteaWorkflowService({ + statePath, + config: { gitea: { webhookSecret: secret, host: 'https://gitea.example', token: 'bot-token' } }, + runner: async () => ({ state: 'waiting_user' }), + }); + const body = JSON.stringify(payload('@ccweb-bot 请检查这个问题')); + const headers = { 'x-gitea-signature': sign(body, secret), 'x-gitea-delivery': 'delivery-1', 'x-gitea-event': 'issue_comment' }; + const accepted = await service.ingestWebhook({ rawBody: body, headers }); + assert.equal(accepted.statusCode, 202); + assert.ok(accepted.task.taskId); + const duplicate = await service.ingestWebhook({ rawBody: body, headers }); + assert.equal(duplicate.payload.duplicate, true); + const botBody = JSON.stringify(payload('@ccweb-bot 自己回帖', 8, 'ccweb-bot')); + const ignored = await service.ingestWebhook({ rawBody: botBody, headers: { ...headers, 'x-gitea-delivery': 'delivery-bot', 'x-gitea-signature': sign(botBody, secret) } }); + assert.equal(ignored.payload.ignored, true); + const invalid = await service.ingestWebhook({ rawBody: body, headers: { ...headers, 'x-gitea-delivery': 'delivery-invalid', 'x-gitea-signature': '00' } }); + assert.equal(invalid.statusCode, 401); + const internalService = createGiteaWorkflowService({ + statePath: tempState(), + config: { gitea: { host: 'https://gitea.example', token: 'bot-token' } }, + runner: async () => ({ state: 'waiting_user' }), + }); + const internalBody = JSON.stringify(payload('@ccweb-bot 内部回调无需额外 Secret')); + const internal = await internalService.ingestWebhook({ + rawBody: internalBody, + headers: { 'x-gitea-delivery': 'delivery-internal', 'x-gitea-event': 'issue_comment' }, + }); + assert.equal(internal.statusCode, 202); + await wait(20); + assert.equal(service.getTask(accepted.task.taskId).state, 'waiting_user'); + assert.equal(service.listTasks().length, 1); + }); + + await check('JSON 持久化与重启恢复 running/queued', () => { + const store = createGiteaWorkflowStore({ filePath: statePath }); + store.createTask({ taskId: 'restart-running', state: 'running', repoKey: 'default:acme/demo' }); + store.createTask({ taskId: 'restart-queued', state: 'queued', repoKey: 'default:acme/demo2' }); + const restored = createGiteaWorkflowStore({ filePath: statePath }); + const recovered = restored.recover({ maxRestartRetries: 1 }); + assert.equal(recovered.find((item) => item.taskId === 'restart-running').state, 'retry_wait'); + assert.equal(recovered.find((item) => item.taskId === 'restart-running').errorCode, 'interrupted_by_restart'); + assert.equal(restored.getTask('restart-queued').state, 'queued'); + assert.throws(() => restored.transitionTask('restart-queued', 'preparing', { expectedVersion: 999 }), /版本已变化/); + }); + + await check('同仓库串行、跨仓库并行', async () => { + const store = createGiteaWorkflowStore({ filePath: tempState() }); + const queue = createGiteaWorkflowQueue({ store, autoRecover: false }); + const started = []; + const released = new Set(); + const runner = async (task) => { + started.push(task.taskId); + while (!released.has(task.taskId)) await wait(5); + return { state: 'succeeded' }; + }; + const tasks = [ + store.createTask({ taskId: 'repo-a-1', state: 'received', repoKey: 'repo-a' }), + store.createTask({ taskId: 'repo-a-2', state: 'received', repoKey: 'repo-a' }), + store.createTask({ taskId: 'repo-b-1', state: 'received', repoKey: 'repo-b' }), + ]; + const waits = tasks.map((task) => queue.enqueue(task.taskId, runner)); + await wait(30); + assert.equal(started.includes('repo-a-1'), true); + assert.equal(started.includes('repo-b-1'), true); + assert.equal(started.includes('repo-a-2'), false); + released.add('repo-a-1'); + await wait(30); + assert.equal(started.includes('repo-a-2'), true); + released.add('repo-a-2'); + released.add('repo-b-1'); + await Promise.all(waits); + }); + + await check('线程级 gitea-mcp 配置不把 token 放入进程级配置', () => { + const mcp = buildGiteaMcpConfig({ gitea: { host: 'https://gitea.example', token: 'abc' } }); + assert.equal(mcp.type, 'stdio'); + assert.deepEqual(mcp.args.slice(0, 2), ['-t', 'stdio']); + assert.equal(mcp.env.GITEA_TOKEN, 'abc'); + assert.equal(mcp.command, 'gitea-mcp'); + const service = createGiteaWorkflowService({ config: { gitea: { host: 'https://gitea.example', token: 'abc' }, codex: { model: 'configured-model', reasoningEffort: 'medium' } }, autoRecover: false }); + const thread = service.buildThreadConfig({ cwd: '/tmp/workspace' }); + assert.equal(thread.collaborationMode.settings.model, 'configured-model'); + assert.equal(thread.collaborationMode.settings.reasoning_effort, 'medium'); + assert.equal(thread.model, undefined); + assert.equal(thread.effort, undefined); + }); + + await check('已规范化 Webhook 任务可通过 server 挂接契约入队', async () => { + const service = createGiteaWorkflowService({ statePath: tempState(), autoRecover: false }); + service.setRunner(async () => ({ state: 'waiting_user' })); + const event = { + instanceId: 'default', deliveryId: 'normalized-1', eventName: 'issue_comment', + repository: { owner: 'acme', name: 'normalized', fullName: 'acme/normalized', cloneUrl: 'https://gitea.example/acme/normalized.git' }, + resource: { kind: 'issue', number: 3, key: 'issue:3' }, + comment: { id: 'comment-3', body: '@ccweb-bot 继续', author: { login: 'alice' } }, + mention: { instruction: '继续' }, + }; + const accepted = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event }); + assert.equal(accepted.ok, true); + assert.equal(accepted.duplicate, false); + const duplicate = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event }); + assert.equal(duplicate.duplicate, true); + await wait(30); + assert.equal(service.getTask('normalized-task').state, 'waiting_user'); + assert.ok(service.store.getTask('normalized-task').metadata.event.repository); + const disabled = service.disableRepository({ repoKey: 'default:acme/normalized', actor: 'alice', reason: '维护' }); + assert.equal(disabled.ok, true); + const rejected = service.enqueueNormalizedTask({ taskId: 'normalized-task-2', deliveryKey: 'default:normalized-2', deliveryId: 'normalized-2', event: { ...event, deliveryId: 'normalized-2' } }); + assert.equal(rejected.ok, false); + assert.equal(rejected.code, 'repository_disabled'); + }); + + console.log(results.join('\n')); + console.log(`全部通过:${results.length} 项`); +} + +run().catch((error) => { + console.error(`FAIL ${error.stack || error}`); + process.exitCode = 1; +}); diff --git a/scripts/gitea-workflow-management-unit.js b/scripts/gitea-workflow-management-unit.js new file mode 100644 index 0000000..4152af0 --- /dev/null +++ b/scripts/gitea-workflow-management-unit.js @@ -0,0 +1,126 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { Readable } = require('stream'); +const { + createGiteaWorkflowManagement, + handleGiteaWorkflowManagementApi, +} = require('../lib/gitea-workflow-management'); + +function request(method, body = null) { + const stream = Readable.from(body == null ? [] : [Buffer.from(JSON.stringify(body))]); + stream.method = method; + return stream; +} + +function response() { + return { + statusCode: 0, + headersSent: false, + headers: {}, + writeHead(code, headers) { this.statusCode = code; this.headers = headers; }, + end(value) { this.body = String(value || ''); this.headersSent = true; }, + }; +} + +function url(value) { return new URL(`http://localhost${value}`); } + +async function call(management, method, pathname, body, authenticated = true) { + const res = response(); + await handleGiteaWorkflowManagementApi(request(method, body), res, url(pathname), management, { + authenticate: () => authenticated, + }); + return { status: res.statusCode, body: JSON.parse(res.body || '{}') }; +} + +async function main() { + const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8'); + const frontend = fs.readFileSync(path.join(__dirname, '..', 'public', 'gitea-workflow.js'), 'utf8'); + assert(html.includes('gitea-workflow-open') && html.includes('gitea-workflow-panel'), '页面入口未挂接'); + assert(html.includes('gitea-workflow.js?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理脚本应使用独立缓存版本'); + assert(html.includes('gitea-workflow.css?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理样式应使用独立缓存版本'); + for (const marker of ['仓库与工作区', '任务队列与 Turn', '运行日志', '操作记录', '暂停全局', '中止 Turn']) { + assert(frontend.includes(marker), `前端缺少 ${marker} 展示/控制`); + } + for (const marker of ['gitea-workflow-actor', 'gitea-workflow-reason', 'gitea-config-webhook-secret', 'gitea-config-concurrency', 'operatorReason', 'controlFields', '填写本次运维操作原因', '凭据仅写入 0600 运行目录,不回显;保存后重启 cc-web 才会应用到 Webhook/MCP。', '全局并发上限', 'Webhook Secret']) { + assert(!frontend.includes(marker), `前端不应再暴露 ${marker}`); + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workflow-')); + const statePath = path.join(tempDir, 'management.json'); + const readOnly = createGiteaWorkflowManagement({ statePath }); + const overview = await call(readOnly, 'GET', '/api/gitea-workflow/overview'); + assert.strictEqual(overview.status, 200); + assert.strictEqual(overview.body.ok, true); + assert.strictEqual(overview.body.available, false); + assert.strictEqual(overview.body.summary.queued, 0); + const config = await call(readOnly, 'GET', '/api/gitea-workflow/config'); + assert.strictEqual(config.status, 200); + assert.strictEqual(config.body.secrets.webhookSecret, undefined); + const invalidHost = await call(readOnly, 'PUT', '/api/gitea-workflow/config', { + host: 'https://user:password@gitea.example', actor: 'alice', reason: 'invalid host test', + }); + assert.strictEqual(invalidHost.status, 400); + assert.strictEqual(invalidHost.body.code, 'invalid_gitea_host'); + const missingReason = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice' }); + assert.strictEqual(missingReason.status, 400); + assert.strictEqual(missingReason.body.code, 'reason_required'); + const unauthorized = await call(readOnly, 'GET', '/api/gitea-workflow/overview', null, false); + assert.strictEqual(unauthorized.status, 401); + const unavailable = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: '维护' }); + assert.strictEqual(unavailable.status, 503); + assert.strictEqual(unavailable.body.code, 'workflow_service_unavailable'); + + const calls = []; + const service = { + getOverview() { return { control: { globalPaused: false }, summary: {} }; }, + listRepositories() { return [{ repoKey: 'default:acme/widget', owner: 'acme', name: 'widget', status: 'active', dirty: true, dirtyReason: '未提交修改' }]; }, + listTasks() { return [ + { taskId: 'task-1', repoKey: 'default:acme/widget', state: 'queued', turnId: null }, + { taskId: 'task-blocked', repoKey: 'default:acme/widget', state: 'blocked_workspace', errorMessage: '目录有未提交修改' }, + ]; }, + listAudit() { return []; }, + listLogs() { return [{ level: 'info', message: 'queued' }]; }, + pause(input) { calls.push(['pause', input]); return { globalPaused: true }; }, + resume(input) { calls.push(['resume', input]); return { globalPaused: false }; }, + disableRepository(input) { calls.push(['disable', input]); return { status: 'disabled' }; }, + enableRepository(input) { calls.push(['enable', input]); return { status: 'active' }; }, + cancelTask(input) { calls.push(['cancel', input]); return { state: 'cancelled' }; }, + abortTask(input) { calls.push(['abort', input]); return { state: 'aborted' }; }, + }; + const management = createGiteaWorkflowManagement({ statePath, workflowService: service }); + const listed = await call(management, 'GET', '/api/gitea-workflow/tasks?state=queued'); + assert.strictEqual(listed.body.total, 1); + assert.strictEqual(listed.body.items[0].taskId, 'task-1'); + const overviewWithDerivedFields = await call(management, 'GET', '/api/gitea-workflow/overview'); + assert.strictEqual(overviewWithDerivedFields.body.summary.maxConcurrency, undefined); + assert.strictEqual(overviewWithDerivedFields.body.repositories[0].queuedCount, 1); + assert.strictEqual(overviewWithDerivedFields.body.repositories[0].dirty, true); + assert.match(overviewWithDerivedFields.body.repositories[0].dirtyReason, /未提交修改/); + assert(overviewWithDerivedFields.body.logs.some((entry) => entry.message === 'queued')); + for (const [route, expected] of [ + ['/control/pause', 'pause'], ['/control/resume', 'resume'], + ['/repos/default%3Aacme%2Fwidget/disable', 'disable'], ['/repos/default%3Aacme%2Fwidget/enable', 'enable'], + ['/tasks/task-1/cancel', 'cancel'], ['/tasks/task-1/abort', 'abort'], + ]) { + const result = await call(management, 'POST', `/api/gitea-workflow${route}`, { actor: 'alice', reason: `test ${expected}`, requestId: expected }); + assert.strictEqual(result.status, 200, expected); + assert.strictEqual(result.body.ok, true, expected); + } + assert.strictEqual(calls.length, 6); + const duplicate = await call(management, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: 'test pause', requestId: 'pause' }); + assert.strictEqual(duplicate.status, 200); + assert.strictEqual(duplicate.body.idempotent, true); + assert.strictEqual(calls.length, 6, '重复 requestId 不应再次调用核心服务'); + const audits = await call(management, 'GET', '/api/gitea-workflow/audit'); + assert.strictEqual(audits.body.total, 6); + assert(audits.body.items.every((entry) => entry.actor === 'alice' && entry.reason.startsWith('test'))); + + const persisted = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(persisted.audits.length, 6); + console.log('gitea-workflow-management-unit: ok'); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/gitea-workflow-ui-unit.js b/scripts/gitea-workflow-ui-unit.js new file mode 100644 index 0000000..27443f3 --- /dev/null +++ b/scripts/gitea-workflow-ui-unit.js @@ -0,0 +1,84 @@ +'use strict'; + +/** Gitea 会话展示契约回归:不启动 server,只校验数据协议和脱敏链路。 */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { normalizeWebhookEvent } = require('../lib/gitea-webhook'); +const { buildWorkflowPrompt } = require('../lib/gitea-workflow-codex'); + +const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); +const appSource = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); +const styleSource = fs.readFileSync(path.join(__dirname, '..', 'public', 'style.css'), 'utf8'); + +function fixturePayload() { + return { + repository: { + owner: { login: 'shiyue' }, + name: 'kan', + full_name: 'shiyue/kan', + clone_url: 'https://gitea.example/shiyue/kan.git', + }, + issue: { number: 1, title: '这里是测试bot' }, + comment: { + id: 9, + body: '@codexbot 再测试一次', + user: { login: 'alice' }, + }, + sender: { login: 'alice' }, + }; +} + +function run() { + const event = normalizeWebhookEvent({ + payload: fixturePayload(), + headers: { 'x-gitea-event': 'issue_comment' }, + botIdentity: { login: 'codexbot' }, + }); + assert.equal(event.ignored, false); + assert.equal(event.resource.title, '这里是测试bot'); + assert.equal(event.mention.instruction, '再测试一次'); + + const prompt = buildWorkflowPrompt({ + taskId: 'task-ui', + turnId: 'turn-ui', + resourceKey: 'default:shiyue/kan:issue:1', + workspacePath: '/home/hdzx/giteabot/shiyue/kan', + instruction: event.mention.instruction, + }); + assert.match(prompt, /工作区:\/home\/hdzx\/giteabot\/shiyue\/kan/); + assert.match(prompt, /用户指令:\n再测试一次/); + assert.match(prompt, /final 隐藏标记/); + + // Codex 仍接收 runtimeText;历史用户消息使用 displayText,避免内部 Prompt 进入用户气泡。 + assert.match(serverSource, /const displayText = giteaDisplayMessage\(task, event\)/); + assert.match(serverSource, /text: displayText, mode: 'yolo', agent: 'codexapp'/); + assert.match(serverSource, /runtimeText: prompt/); + assert.match(serverSource, /content: displayTextValue/); + assert.match(serverSource, /const giteaSource = normalizeGiteaSource\(options\.giteaSource\)/); + assert.match(serverSource, /persistedUserMessage\.giteaSource = giteaSource/); + + // 标题优先 Issue/PR resource.title,旧资源键仅作兜底。 + assert.match(serverSource, /title: issueTitle \|\| legacyTitle/); + assert.match(serverSource, /session\.title === legacyTitle/); + assert.match(serverSource, /let sessionCreated = false/); + assert.match(serverSource, /if \(sessionCreated && !presentationChanged\) broadcastSessionList\(\)/); + + // 前端既显示来源标记,也会把旧完整 Prompt 脱敏为简短来源消息。 + assert.match(appSource, /来自 gitea 消息:/); + assert.match(appSource, /gitea-message-label/); + assert.match(appSource, /session-item-source-badge/); + assert.match(appSource, /function getGiteaVisibleMessageContent/); + assert.match(appSource, /已收到工单请求,请读取工单上下文/); + assert.match(styleSource, /\.msg\.gitea-message \.msg-bubble/); + assert.match(styleSource, /\.session-item-source-badge/); + + console.log('Gitea Workflow UI unit checks passed'); +} + +try { + run(); +} catch (error) { + console.error(error.stack || error.message || error); + process.exitCode = 1; +} diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 89f022d..a39cfdb 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -826,6 +826,44 @@ function completeApprovalTurn(thread, turnId) { }); } +function completeElicitationTurn(thread, turnId, text) { + const isUrl = /elicitation\s+url/i.test(text); + const isOpenAiForm = /elicitation\s+openai/i.test(text); + const params = isUrl + ? { + threadId: thread.id, + turnId, + serverName: 'fixture-mcp', + mode: 'url', + message: '请完成测试授权后返回 cc-web。', + url: 'https://example.com/mcp-elicitation-fixture', + elicitationId: 'fixture-elicitation-url', + _meta: { fixture: true }, + } + : { + threadId: thread.id, + turnId, + serverName: 'fixture-mcp', + mode: isOpenAiForm ? 'openai/form' : 'form', + message: '请填写 MCP 测试信息。', + requestedSchema: { + type: 'object', + properties: { + displayName: { type: 'string', title: '显示名称', description: '用于回归断言。' }, + count: { type: 'integer', title: '数量', minimum: 1, default: 2 }, + enabled: { type: 'boolean', title: '启用', default: true }, + tags: { type: 'array', title: '标签', items: { type: 'string', enum: ['alpha', 'beta'] } }, + }, + required: ['displayName'], + }, + _meta: { fixture: true }, + }; + requestClient('mcpServer/elicitation/request', params, (message) => { + const result = message.result || {}; + completeTurn(thread, turnId, `elicitation result: ${JSON.stringify(result)}`); + }); +} + function completeEmptyReasoningTurn(thread, turnId, text) { send({ method: 'item/started', @@ -983,6 +1021,11 @@ function startTurn(params) { return { turn: { id: turnId, status: 'running', items: [] } }; } + if (/elicitation/i.test(text)) { + completeElicitationTurn(thread, turnId, text); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80; if (/recover/i.test(text)) { send({ diff --git a/scripts/regression.js b/scripts/regression.js index a2acdfa..33ce361 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -2627,6 +2627,10 @@ function assertFrontendPrimaryCodexAppUiContract() { serverSource.includes("const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);"), 'Server explicit agent support should remain available for API/MCP paths' ); + assert(serverSource.includes("case 'mcpServer/elicitation/request':"), 'Server should route MCP elicitation requests through the interactive response path'); + assert(serverSource.includes('pendingCodexAppElicitations') && serverSource.includes('10 * 60 * 1000') && serverSource.includes('resolvePendingCodexAppElicitationsForSession'), 'MCP elicitation should keep a bounded pending lifecycle with timeout and session cleanup'); + assert(source.includes("case 'codex_app_elicitation_request':"), 'Frontend should render MCP elicitation requests'); + assert(source.includes("type: 'codex_app_elicitation_response'"), 'Frontend should send structured MCP elicitation responses'); } function assertSetTitleMcpContract() { @@ -2668,6 +2672,7 @@ function assertSessionItemTooltipContract() { function assertSessionProjectSnapshotContract() { const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const mergeSource = extractFunctionSource(frontendSource, 'mergeSessionListSnapshot'); + const normalizeGiteaSourceSource = extractFunctionSource(frontendSource, 'normalizeGiteaSource'); const api = new Function(` let sessions = []; function normalizeAgent(agent) { @@ -2680,6 +2685,7 @@ function assertSessionProjectSnapshotContract() { function compareSessionUpdatedDesc(a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); } + ${normalizeGiteaSourceSource} ${mergeSource} return { mergeSessionListSnapshot, @@ -7991,6 +7997,43 @@ async function main() { assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' })); + const elicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId); + assert(elicitationRequest.mode === 'form', 'Codex App should forward MCP form elicitation requests'); + assert(elicitationRequest.serverName === 'fixture-mcp', 'MCP elicitation should preserve server name'); + assert(elicitationRequest.requestedSchema?.properties?.displayName, 'MCP elicitation should forward requested schema'); + assert(!messages.some((msg) => msg.type === 'system_message' && /mcpServer\/elicitation\/request/.test(msg.message || '') && /保守策略拒绝/.test(msg.message || '')), 'MCP elicitation requests should not be rejected by the generic unsupported-request path'); + ws.send(JSON.stringify({ + type: 'codex_app_elicitation_response', + action: 'accept', + sessionId: codexAppSession.sessionId, + requestId: elicitationRequest.requestId, + content: { displayName: '回归用户', count: 3, enabled: true, tags: ['alpha'] }, + })); + const elicitationSubmitted = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已提交 MCP elicitation/.test(msg.message || '')); + assert(/已提交 MCP elicitation/.test(elicitationSubmitted.message || ''), 'MCP elicitation acceptance should show confirmation hint'); + const elicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /回归用户/.test(msg.text || '')); + assert(/回归用户/.test(elicitationDelta.text || ''), 'Codex App should continue after MCP elicitation response'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + + ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation openai', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' })); + const openAiElicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId); + assert(openAiElicitationRequest.mode === 'openai/form', 'Codex App should forward openai/form elicitation requests'); + ws.send(JSON.stringify({ type: 'codex_app_elicitation_response', action: 'decline', sessionId: codexAppSession.sessionId, requestId: openAiElicitationRequest.requestId })); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已拒绝 MCP elicitation/.test(msg.message || '')); + const openAiElicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /"action":"decline"/.test(msg.text || '')); + assert(/"action":"decline"/.test(openAiElicitationDelta.text || ''), 'MCP openai/form elicitation should support decline'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + + ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation url', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' })); + const urlElicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId); + assert(urlElicitationRequest.mode === 'url' && /example\.com/.test(urlElicitationRequest.url || ''), 'Codex App should forward URL elicitation requests'); + ws.send(JSON.stringify({ type: 'codex_app_elicitation_response', action: 'cancel', sessionId: codexAppSession.sessionId, requestId: urlElicitationRequest.requestId })); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已取消 MCP elicitation/.test(msg.message || '')); + const urlElicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /"action":"cancel"/.test(msg.text || '')); + assert(/"action":"cancel"/.test(urlElicitationDelta.text || ''), 'MCP URL elicitation should support cancel'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' })); const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId); assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests'); diff --git a/server.js b/server.js index 2fadb50..1c875c3 100644 --- a/server.js +++ b/server.js @@ -10,8 +10,20 @@ const { createCodexAppServerClient } = require('./lib/codex-app-server-client'); const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client'); const { createCodexAppRuntime } = require('./lib/codex-app-runtime'); const { createCodexRolloutStore } = require('./lib/codex-rollouts'); +const { + mergeGiteaThreadConfig, + extractTurnId: extractGiteaWorkflowTurnId, + extractThreadId: extractGiteaWorkflowThreadId, +} = require('./lib/gitea-workflow-codex'); const { createSessionSearchIndex } = require('./lib/session-search-index'); const { createUsageStatisticsIndex, UsageStatisticsError } = require('./lib/usage-statistics'); +const { + createGiteaWebhookReceiver, + createGiteaWebhookRoute, +} = require('./lib/gitea-webhook'); +const { + createGiteaWorkspaceManager, +} = require('./lib/gitea-workspace'); const { TOOLS: CCWEB_MCP_TOOLS, CCWEB_REPLY_MODES, @@ -35,6 +47,22 @@ const { CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS, createTaskStatusClassifier, } = require('./lib/task-board-classifier'); +const { + createGiteaWorkflowManagement, + handleGiteaWorkflowManagementApi, +} = require('./lib/gitea-workflow-management'); +const { + createGiteaWorkflowService, +} = require('./lib/gitea-workflow-service'); +const { + buildGiteaMcpConfig, + buildWorkflowPrompt, + classifyReplyComments, + encodeMarker, + parseWorkflowMarker, + isBotComment: isGiteaWorkflowBotComment, +} = require('./lib/gitea-workflow-codex'); +const { probeGiteaMcp } = require('./lib/gitea-mcp-probe'); const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' }; if (process.argv.includes('--ccweb-mcp-server')) { @@ -141,6 +169,7 @@ const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX function computeFrontendAssetVersion() { try { const hash = crypto.createHash('sha256'); + // 保持既有前端版本契约;Gitea 管理资源沿用同一版本号,不改变旧客户端的校验哈希。 for (const assetName of ['index.html', 'app.js', 'task-board.js', 'task-board.css']) { hash.update(assetName); hash.update('\0'); @@ -152,10 +181,27 @@ function computeFrontendAssetVersion() { return ''; } } +function computeGiteaWorkflowAssetVersion() { + try { + const hash = crypto.createHash('sha256'); + for (const assetName of ['gitea-workflow.js', 'gitea-workflow.css']) { + hash.update(assetName); + hash.update('\0'); + hash.update(fs.readFileSync(path.join(PUBLIC_DIR, assetName))); + hash.update('\0'); + } + return hash.digest('hex').slice(0, 16); + } catch { + return ''; + } +} function injectFrontendAssetVersion(indexHtml) { const frontendAssetVersion = computeFrontendAssetVersion(); - return String(indexHtml || '').replaceAll('__CC_WEB_FRONTEND_ASSET_VERSION__', frontendAssetVersion); + const giteaWorkflowAssetVersion = computeGiteaWorkflowAssetVersion(); + return String(indexHtml || '') + .replaceAll('__CC_WEB_FRONTEND_ASSET_VERSION__', frontendAssetVersion) + .replaceAll('__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__', giteaWorkflowAssetVersion); } const CODEX_APP_STATE_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_LOAD_MAX_BYTES', 4 * 1024 * 1024, { min: CODEX_APP_STATE_MAX_BYTES }); const CODEX_APP_STATE_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 192 * 1024, { min: 4096 }); @@ -236,11 +282,113 @@ const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json'); const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json'); const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.json'); const TASK_BOARD_STATUS_CONFIG_PATH = path.join(CONFIG_DIR, 'task-board-statuses.json'); +const GITEA_WORKFLOW_MANAGEMENT_STATE_PATH = path.join(CONFIG_DIR, 'gitea-workflow-management.json'); fs.mkdirSync(SESSIONS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true }); fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); +// Gitea Workflow runner 与 Codex App turn 的等待桥接。 +const giteaWorkflowWaiters = new Map(); + +// 管理页面只依赖稳定的管理适配器;核心 Gitea Workflow 服务可在后续启动链路中注入。 +const giteaWorkflowManagement = createGiteaWorkflowManagement({ + statePath: GITEA_WORKFLOW_MANAGEMENT_STATE_PATH, + secretsPath: path.join(CONFIG_DIR, 'gitea-workflow-secrets.json'), + onConfigUpdate: ({ settings = {}, secrets = {} } = {}) => { + // 管理页保存后立即更新内存中的 Webhook/MCP 配置,不要求重启 cc-web。 + giteaConfiguredHost = String(settings.host || '').trim(); + giteaConfiguredBotLogin = String(settings.botLogin || 'ccweb-bot').trim() || 'ccweb-bot'; + giteaConfiguredWorkspaceRoot = String(settings.workspaceRoot || '').trim() + || path.join(APP_DIR, 'gitea-workspaces'); + giteaConfiguredWebhookSecret = String(secrets.webhookSecret || '').trim(); + giteaConfiguredBotToken = String(secrets.botToken || '').trim(); + giteaBotLogin = giteaConfiguredBotLogin; + giteaWebhookSecret = readGiteaSecret(giteaConfiguredWebhookSecret, 'GITEA_WEBHOOK_SECRET_FILE'); + giteaBotToken = readGiteaSecret(giteaConfiguredBotToken, 'GITEA_BOT_TOKEN_FILE'); + if (giteaWorkspaceManager) { + giteaWorkspaceManager = createGiteaWorkspaceManager({ + instanceId: giteaInstanceId, + workspaceRoot: giteaConfiguredWorkspaceRoot, + allowedHost: giteaConfiguredHost, + recordsPath: process.env.CC_WEB_GITEA_REPOSITORY_FILE || path.join(CONFIG_DIR, 'gitea-repositories.json'), + botUsername: giteaBotLogin, + token: giteaBotToken, + }); + } + if (giteaWorkflowService && typeof giteaWorkflowService.updateConfig === 'function') { + giteaWorkflowService.updateConfig({ + botLogin: settings.botLogin, + workspaceRoot: settings.workspaceRoot, + defaultBranchOverride: settings.defaultBranch, + host: settings.host, + botToken: secrets.botToken, + webhookSecret: secrets.webhookSecret, + }); + } + if (giteaWebhookReceiver && typeof giteaWebhookReceiver.updateConfig === 'function') { + giteaWebhookReceiver.updateConfig({ + secret: secrets.webhookSecret, + botToken: secrets.botToken, + botLogin: settings.botLogin, + }); + } + }, +}); +const envGiteaSettings = { + host: process.env.CC_WEB_GITEA_HOST || process.env.GITEA_HOST || '', + botLogin: process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN || 'ccweb-bot', + workspaceRoot: process.env.CC_WEB_GITEA_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT || '', + webhookSecretConfigured: Boolean(readGiteaSecret( + process.env.CC_WEB_GITEA_WEBHOOK_SECRET || process.env.GITEA_WEBHOOK_SECRET, + 'GITEA_WEBHOOK_SECRET_FILE', + )), + botTokenConfigured: Boolean(readGiteaSecret( + process.env.CC_WEB_GITEA_BOT_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_BOT_TOKEN, + 'GITEA_BOT_TOKEN_FILE', + )), +}; +const existingGiteaSettings = giteaWorkflowManagement.configuration().settings; +// 仅把实际提供的环境变量同步到管理镜像;空环境字段不能覆盖页面已保存的配置。 +// 这样在“地址由环境变量提供、工作区由页面配置”的混合部署中,重启不会丢设置。 +const environmentGiteaPatch = Object.fromEntries( + Object.entries(envGiteaSettings).filter(([key, value]) => ( + key === 'botLogin' ? Boolean(process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN) + : Boolean(value) + )), +); +if (Object.keys(environmentGiteaPatch).length > 0) { + const nextSettings = { ...existingGiteaSettings, ...environmentGiteaPatch }; + const changed = Object.keys(environmentGiteaPatch).some((key) => existingGiteaSettings[key] !== nextSettings[key]); + if (changed) giteaWorkflowManagement.setState({ settings: { ...nextSettings, updatedBy: 'environment' } }); +} +const savedGiteaWorkflowConfig = giteaWorkflowManagement.runtimeConfiguration(); +let giteaConfiguredHost = savedGiteaWorkflowConfig.settings.host + || process.env.CC_WEB_GITEA_HOST || process.env.GITEA_HOST || ''; +let giteaConfiguredBotLogin = savedGiteaWorkflowConfig.settings.botLogin + || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN || 'ccweb-bot'; +let giteaConfiguredBotToken = savedGiteaWorkflowConfig.secrets.botToken + || process.env.CC_WEB_GITEA_BOT_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_BOT_TOKEN || ''; +let giteaConfiguredWebhookSecret = savedGiteaWorkflowConfig.secrets.webhookSecret + || process.env.CC_WEB_GITEA_WEBHOOK_SECRET || process.env.GITEA_WEBHOOK_SECRET || ''; +let giteaConfiguredWorkspaceRoot = savedGiteaWorkflowConfig.settings.workspaceRoot + || process.env.CC_WEB_GITEA_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT || path.join(APP_DIR, 'gitea-workspaces'); +const giteaWorkflowService = createGiteaWorkflowService({ + statePath: process.env.CC_WEB_GITEA_WORKFLOW_STATE || path.join(CONFIG_DIR, 'gitea-workflow.json'), + autoRecover: false, + config: { + instanceId: process.env.CC_WEB_GITEA_INSTANCE_ID || 'default', + botLogin: giteaConfiguredBotLogin, + botId: process.env.CC_WEB_GITEA_BOT_ID || null, + gitea: { + host: giteaConfiguredHost, + token: giteaConfiguredBotToken, + webhookSecret: giteaConfiguredWebhookSecret, + defaultBranchOverride: savedGiteaWorkflowConfig.settings.defaultBranch || '', + }, + }, +}); +giteaWorkflowManagement.setWorkflowService(giteaWorkflowService); // === Process Lifecycle Logger === const LOG_FILE = path.join(LOGS_DIR, 'process.log'); @@ -268,6 +416,507 @@ function plog(level, event, data = {}) { } catch {} } +// Gitea Webhook 只负责验签、去重和把规范化任务交给后续工作流;clone/fetch +// 在独立工作区管理器中执行,避免把凭据、锁和 Git 副作用散落在 HTTP 路由里。 +function readGiteaSecret(value, fileEnvName) { + if (String(value || '').trim()) return String(value).trim(); + const filePath = String(process.env[fileEnvName] || '').trim(); + if (!filePath) return ''; + try { return fs.readFileSync(path.resolve(filePath), 'utf8').trim(); } catch { return ''; } +} + +const giteaInstanceId = process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID || 'default'; +let giteaBotLogin = giteaConfiguredBotLogin; +let giteaWebhookSecret = readGiteaSecret( + giteaConfiguredWebhookSecret, + 'GITEA_WEBHOOK_SECRET_FILE', +); +let giteaBotToken = readGiteaSecret( + giteaConfiguredBotToken, + 'GITEA_BOT_TOKEN_FILE', +); +let giteaWorkspaceManager = createGiteaWorkspaceManager({ + instanceId: giteaInstanceId, + workspaceRoot: giteaConfiguredWorkspaceRoot, + allowedHost: giteaConfiguredHost, + recordsPath: process.env.CC_WEB_GITEA_REPOSITORY_FILE || path.join(CONFIG_DIR, 'gitea-repositories.json'), + botUsername: giteaBotLogin, +}); +const giteaWebhookReceiver = createGiteaWebhookReceiver({ + secret: giteaWebhookSecret, + instanceId: giteaInstanceId, + botLogin: giteaBotLogin, + botId: process.env.CC_WEB_GITEA_BOT_ID || process.env.GITEA_BOT_ID || null, + botToken: giteaBotToken, + requireBotToken: true, + deliveryFile: process.env.CC_WEB_GITEA_DELIVERY_FILE || path.join(CONFIG_DIR, 'gitea-webhook-deliveries.json'), + onTask: async (task) => { + const accepted = giteaWorkflowService.enqueueNormalizedTask(task); + plog('INFO', accepted.duplicate + ? 'gitea_webhook_duplicate' + : (accepted.ok ? 'gitea_webhook_task_enqueued' : 'gitea_webhook_task_rejected'), { + taskId: accepted.task?.taskId || task.taskId, + deliveryId: task.deliveryId, + repository: task.event.repository.fullName, + resource: task.event.resource.key, + }); + // Gitea 讨论串只保留一条起始回执;运行中、等待用户和成功状态不再刷屏。 + if (accepted.ok && !accepted.duplicate) void postGiteaWorkflowStatus(task, '已收到'); + if (!accepted.ok && accepted.code === 'repository_disabled') { + void postGiteaWorkflowStatus(task, '仓库已停用,本次请求未执行'); + } + }, + onTaskError: (error, task) => { + plog('WARN', 'gitea_webhook_task_failed', { + taskId: task?.taskId || null, + deliveryId: task?.deliveryId || null, + code: error?.code || 'task_error', + message: error?.message || String(error), + }); + if (task) void postGiteaWorkflowStatus(task, '工作流接收失败,请检查 cc-web 日志'); + }, +}); + +async function giteaWorkflowRequest(method, url, token, body) { + if (typeof fetch !== 'function') throw new Error('当前运行时不支持 fetch'); + const headers = { Authorization: `token ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' }; + const response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); + const textBody = await response.text(); + let parsed = null; + try { parsed = textBody ? JSON.parse(textBody) : null; } catch {} + if (!response.ok) throw Object.assign(new Error(`Gitea API ${response.status}`), { code: `gitea_api_${response.status}`, status: response.status }); + return parsed; +} + +async function postGiteaWorkflowStatus(task, statusText) { + const host = String(giteaConfiguredHost || '').trim().replace(/\/$/, ''); + if (!host || !giteaBotToken) return { skipped: true }; + const event = task?.event || {}; + const repo = event.repository || {}; + const resource = event.resource || {}; + if (!repo.owner || !repo.name || !resource.number) return { skipped: true }; + const url = `${host}/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/issues/${encodeURIComponent(resource.number)}/comments`; + const resourceKey = `${task.instanceId || giteaInstanceId}:${repo.owner}/${repo.name}:${resource.kind}:${resource.number}`; + const statusKey = `${String(task.taskId || 'unknown').slice(0, 160)}:${String(statusText || '').slice(0, 240)}`; + const storedTask = task.taskId ? giteaWorkflowService.store.getTask(task.taskId) : null; + const sentStatuses = Array.isArray(storedTask?.metadata?.statusComments) + ? storedTask.metadata.statusComments + : []; + if (sentStatuses.includes(statusKey)) return { ok: true, duplicate: true }; + const body = `giteabot: ${statusText}\n任务:${task.taskId || 'unknown'}\n${encodeMarker({ taskId: task.taskId, turnId: task.turnId || 'pending', resourceKey, kind: 'status' })}`; + try { + const comment = await giteaWorkflowRequest('POST', url, giteaBotToken, { body }); + const latestTask = task.taskId ? giteaWorkflowService.store.getTask(task.taskId) : null; + if (latestTask) { + const latestStatuses = Array.isArray(latestTask.metadata?.statusComments) + ? latestTask.metadata.statusComments + : []; + giteaWorkflowService.store.upsertTask({ + ...latestTask, + metadata: { ...latestTask.metadata, statusComments: [...new Set([...latestStatuses, statusKey])].slice(-64) }, + }); + } + return { ok: true, comment }; + } catch (error) { + plog('WARN', 'gitea_workflow_status_comment_failed', { taskId: task.taskId, code: error.code || 'status_comment_failed' }); + return { ok: false, code: error.code || 'status_comment_failed' }; + } +} + +async function verifyGiteaWorkflowReply(waiter, body) { + const host = String(giteaConfiguredHost || '').trim().replace(/\/$/, ''); + const token = giteaBotToken; + if (!host || !token) return { status: 'unknown' }; + const event = waiter.task?.event || {}; + const repo = event.repository || {}; + const resource = event.resource || {}; + const commentsUrl = `${host}/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/issues/${encodeURIComponent(resource.number)}/comments?limit=100`; + try { + const comments = await giteaWorkflowRequest('GET', commentsUrl, token); + const botIdentity = { + login: giteaBotLogin, + id: process.env.CC_WEB_GITEA_BOT_ID || null, + }; + const turnIds = [...new Set([ + waiter.workflowTurnId, + waiter.markerTurnId, + ...(Array.isArray(waiter.markerTurnIds) ? waiter.markerTurnIds : []), + ].filter(Boolean))]; + const waitingComment = Array.isArray(comments) ? comments.find((comment) => { + const marker = parseWorkflowMarker(comment?.body || comment?.content || ''); + return marker + && String(marker.kind || '').toLowerCase() === 'waiting_user' + && marker.taskId === waiter.task.taskId + && marker.resourceKey === waiter.task.resourceKey + && turnIds.includes(marker.turnId) + && isGiteaWorkflowBotComment(comment, botIdentity); + }) : null; + if (waitingComment) return { status: 'waiting_user', comment: waitingComment, commentId: waitingComment.id || null }; + return classifyReplyComments(comments, { + taskId: waiter.task.taskId, + turnId: waiter.workflowTurnId, + turnIds, + resourceKey: waiter.task.resourceKey, + }, botIdentity); + } catch (error) { + plog('WARN', 'gitea_workflow_reply_query_failed', { taskId: waiter.task.taskId, code: error.code || 'reply_query_failed' }); + return { status: 'unknown', error: error.message }; + } +} + +async function settleGiteaWorkflowTurn(sessionId, entry, session) { + const waiter = giteaWorkflowWaiters.get(sessionId); + if (!waiter || waiter.entry !== entry) return; + // Codex App 的实际 turnId 优先作为回执关联标识;旧 waiter 中的预分配值仅作兼容。 + if (entry.turnId) waiter.workflowTurnId = entry.turnId; + const currentTask = giteaWorkflowService.store.getTask(waiter.task.taskId); + if (currentTask) giteaWorkflowService.store.upsertTask({ + ...currentTask, + threadId: entry.threadId || currentTask.threadId, + turnId: waiter.workflowTurnId, + turnState: entry.turnStatus === 'waiting_user' || entry.waitingUserRequested === true ? 'waiting_user' : 'completed', + turnUpdatedAt: new Date().toISOString(), + }); + const currentSession = giteaWorkflowService.store.getSession(waiter.task.sessionKey); + if (currentSession) giteaWorkflowService.store.upsertSession({ ...currentSession, threadId: entry.threadId || currentSession.threadId, lastTurnId: waiter.workflowTurnId }); + const updateTurnState = (turnState) => { + const latest = giteaWorkflowService.store.getTask(waiter.task.taskId); + if (!latest) return; + giteaWorkflowService.store.upsertTask({ ...latest, turnState, turnUpdatedAt: new Date().toISOString() }); + }; + if (entry.turnStatus === 'waiting_user' || entry.waitingUserRequested === true) { + giteaWorkflowWaiters.delete(sessionId); + waiter.resolve({ waitingUser: true, threadId: entry.threadId, turnId: waiter.workflowTurnId }); + return; + } + const result = await verifyGiteaWorkflowReply(waiter, entry.fullText || ''); + if (result.status === 'confirmed') { + giteaWorkflowWaiters.delete(sessionId); + waiter.resolve({ replyConfirmed: true, threadId: entry.threadId, turnId: waiter.workflowTurnId }); + return; + } + if (result.status === 'waiting_user') { + giteaWorkflowWaiters.delete(sessionId); + updateTurnState('waiting_user'); + waiter.resolve({ waitingUser: true, threadId: entry.threadId, turnId: waiter.workflowTurnId }); + return; + } + // 查询异常代表外部状态未知;遵循保守策略,不因无法确认而重复写操作。 + if (result.status === 'unknown') { + updateTurnState('failed'); + giteaWorkflowWaiters.delete(sessionId); + waiter.resolve({ + state: 'failed_reply', + errorCode: 'reply_verification_unknown', + errorMessage: result.error || 'Gitea 回帖查询状态未知。', + }); + return; + } + // 只有确认缺失时才允许一次“只补发回执”的有限补偿;绝不重新执行原始修改指令。 + if (waiter.replyRetryCount < 1) { + updateTurnState('retrying_reply'); + waiter.replyRetryCount += 1; + waiter.entry = null; + waiter.markerTurnIds = [...new Set([ + ...(Array.isArray(waiter.markerTurnIds) ? waiter.markerTurnIds : []), + waiter.workflowTurnId, + ].filter(Boolean))]; + const retryText = [ + '只补发上一轮最终回复到原 Gitea 讨论串。不得重复修改代码、提交或执行其他写操作。', + `请使用以下关联标识:${encodeMarker({ taskId: waiter.task.taskId, turnId: waiter.workflowTurnId, resourceKey: waiter.task.resourceKey, state: 'final' })}`, + `上一轮回复正文:\n${String(entry.fullText || '').slice(-12000)}`, + ].join('\n'); + try { + const dispatchResult = handleMessage(null, { type: 'message', sessionId, text: retryText, mode: 'yolo' }, { + hideInHistory: true, + runtimeText: retryText, + mcpContext: { giteaWorkflow: true }, + giteaWorkflow: { taskId: waiter.task.taskId, kind: 'reply_retry', mcp: { host: waiter.giteaHost, accessToken: waiter.giteaToken } }, + }); + if (dispatchResult && dispatchResult.ok === false) { + throw Object.assign(new Error(dispatchResult.message || '回帖补发 turn 未启动。'), { code: 'reply_retry_dispatch_failed' }); + } + } catch (error) { + waiter.replyRetryCount = 1; + waiter.entry = entry; + void settleGiteaWorkflowTurn(sessionId, entry, session).catch((settleError) => { + giteaWorkflowWaiters.delete(sessionId); + waiter.resolve({ state: 'failed_reply', errorCode: 'reply_settlement_failed', errorMessage: settleError?.message || String(settleError || '') }); + }); + } + return; + } + const host = String(giteaConfiguredHost || '').trim().replace(/\/$/, ''); + const token = giteaBotToken; + const event = waiter.task.event || {}; + const repo = event.repository || {}; + const resource = event.resource || {}; + if (host && token) { + try { + const url = `${host}/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/issues/${encodeURIComponent(resource.number)}/comments`; + const fallbackBody = `${String(entry.fullText || '本轮 Agent 已完成,但 MCP 回帖未确认。').slice(-12000)}\n${encodeMarker({ taskId: waiter.task.taskId, turnId: waiter.workflowTurnId, resourceKey: waiter.task.resourceKey, state: 'fallback' })}`; + await giteaWorkflowRequest('POST', url, token, { body: fallbackBody }); + giteaWorkflowWaiters.delete(sessionId); + updateTurnState('completed'); + waiter.resolve({ replyConfirmed: true, usedRestFallback: true, threadId: entry.threadId, turnId: waiter.workflowTurnId }); + return; + } catch (error) { + plog('WARN', 'gitea_workflow_rest_fallback_failed', { taskId: waiter.task.taskId, code: error.code || 'fallback_failed' }); + } + } + giteaWorkflowWaiters.delete(sessionId); + updateTurnState('failed'); + waiter.resolve({ state: 'failed_reply', errorCode: 'reply_missing', errorMessage: 'MCP 回帖和 REST 兜底均未成功。' }); +} + +function giteaSourceFromEvent(event = {}) { + const repository = event.repository || {}; + const resource = event.resource || {}; + const fullName = String(repository.fullName || '').trim() + || [repository.owner, repository.name].filter(Boolean).join('/'); + return normalizeGiteaSource({ + repository: fullName, + resourceKind: resource.kind, + number: resource.number, + title: resource.title || event.issue?.title || event.pullRequest?.title || '', + }); +} + +function giteaDisplayMessage(task = {}, event = {}) { + const instruction = String(task.instruction || event.mention?.instruction || '').trim(); + return `来自 gitea 消息:${instruction || '未提供具体指令,请读取工单上下文。'}`; +} + +giteaWorkflowService.setRunner(async (task, context = {}) => { + const repository = giteaWorkflowService.store.getRepository(task.repoKey) || task.metadata?.event?.repository || {}; + const workflowSession = giteaWorkflowService.store.getSession(task.sessionKey) || {}; + const token = giteaBotToken; + const event = task.metadata?.event || {}; + const giteaMcpCommand = resolveGiteaMcpCommand(); + // MCP 命令缺失时在启动 Codex App 前快速失败,避免线程进入永久 running 等待。 + if (!commandExistsForMcp(giteaMcpCommand)) { + throw Object.assign(new Error(`Gitea MCP 命令不可用:${giteaMcpCommand || 'gitea-mcp'}。请确认 cc-web/bin/gitea-mcp 已随部署包存在,或配置 CC_WEB_GITEA_MCP_COMMAND。`), { + code: 'gitea_mcp_not_found', + }); + } + const host = String(giteaConfiguredHost || '').trim().replace(/\/$/, ''); + if (!host) { + throw Object.assign(new Error('Gitea 地址未配置,无法启动 gitea-mcp。'), { code: 'gitea_host_missing' }); + } + if (!token) { + throw Object.assign(new Error('ccweb-bot Token 未配置,无法启动 gitea-mcp。'), { code: 'gitea_bot_token_missing' }); + } + const giteaMcp = buildGiteaMcpConfig({ host, token, command: giteaMcpCommand }); + await probeGiteaMcp({ + command: giteaMcp.command, + args: giteaMcp.args, + env: giteaMcp.env, + cwd: APP_DIR, + timeoutMs: process.env.CC_WEB_GITEA_MCP_STARTUP_TIMEOUT_MS, + }); + const giteaSource = giteaSourceFromEvent(event); + const issueTitle = giteaSource?.title || ''; + const legacyTitle = `Gitea ${task.resourceKey}`; + const workflowRepository = giteaWorkflowService.store.getRepository(task.repoKey) || repository; + const allowDirtyContinuation = workflowRepository.dirty === true + && workflowRepository.dirtySessionKey === task.sessionKey; + let workspace; + try { + workspace = await giteaWorkspaceManager.ensureRepository(event.repository || repository, { + token, + allowDirty: allowDirtyContinuation, + dirtySessionKey: task.sessionKey, + }); + const currentRepository = giteaWorkflowService.store.getRepository(task.repoKey) || repository; + giteaWorkflowService.store.upsertRepository({ + ...currentRepository, + workspacePath: workspace.workspacePath, + status: allowDirtyContinuation ? 'blocked' : 'active', + dirty: allowDirtyContinuation, + dirtyReason: allowDirtyContinuation ? (currentRepository.dirtyReason || workspace.dirtyReason) : null, + dirtySessionKey: allowDirtyContinuation ? task.sessionKey : null, + lastFetchAt: new Date().toISOString(), + activeTaskId: task.taskId, + }); + } catch (error) { + if (error?.code === 'blocked_workspace') { + const currentRepository = giteaWorkflowService.store.getRepository(task.repoKey) || repository; + giteaWorkflowService.store.upsertRepository({ + ...currentRepository, + status: 'blocked', + dirty: true, + dirtyReason: error.message || '工作区存在未提交修改或冲突。', + dirtySessionKey: currentRepository.dirtySessionKey || task.sessionKey, + activeTaskId: null, + }); + } + throw error; + } + const clearRepositoryActiveTask = () => { + try { + const currentRepository = giteaWorkflowService.store.getRepository(task.repoKey); + if (currentRepository?.activeTaskId === task.taskId) { + giteaWorkflowService.store.upsertRepository({ ...currentRepository, activeTaskId: null }); + } + } catch (error) { + plog('WARN', 'gitea_workflow_repository_cleanup_failed', { taskId: task.taskId, code: error.code || 'repository_cleanup_failed' }); + } + }; + let session = workflowSession?.ccwebSessionId ? loadSession(workflowSession.ccwebSessionId) : null; + try { + let sessionCreated = false; + if (!session) { + const created = createPersistentConversationSession({ + title: issueTitle || legacyTitle, + cwd: workspace.workspacePath, + agent: 'codexapp', + mode: 'yolo', + giteaSource, + }, { strict: true, defaultAgent: 'codexapp', defaultMode: 'yolo', inheritSourceMode: false, requireAbsoluteCwd: true }); + if (!created?.ok) throw Object.assign(new Error(created?.message || '创建 Gitea 会话失败'), { code: 'session_create_failed' }); + session = created.session; + sessionCreated = true; + giteaWorkflowService.store.upsertSession({ ...workflowSession, sessionKey: task.sessionKey, ccwebSessionId: session.id, threadId: session.codexAppThreadId || null }); + } + // 兼容已经创建过的旧 Gitea 会话:只替换资源键兜底标题,不覆盖用户手动重命名。 + let presentationChanged = false; + if (giteaSource && JSON.stringify(session.giteaSource || null) !== JSON.stringify(giteaSource)) { + session.giteaSource = giteaSource; + presentationChanged = true; + } + if (issueTitle && session.title === legacyTitle && !isTitleLockedByUser(session)) { + session.title = issueTitle; + session.titleSource = 'system'; + presentationChanged = true; + } + if (presentationChanged) { + session.updated = new Date().toISOString(); + saveSession(session); + sendSessionEventToViewers(session.id, { + type: 'session_renamed', + sessionId: session.id, + title: session.title, + ...publicTitleMetadata(session), + }); + broadcastSessionList(); + } + // 后台 Webhook 没有浏览器 WebSocket,handleMessage 无法替它刷新侧边栏; + // 新建会话后主动广播一次,确保用户无需手动刷新就能看到 Gitea 会话。 + if (sessionCreated && !presentationChanged) broadcastSessionList(); + } catch (error) { + clearRepositoryActiveTask(); + throw error; + } + const workflowTurnId = `gitea-turn-${crypto.randomUUID()}`; + const prompt = buildWorkflowPrompt({ ...task, event, author: task.actor, turnId: workflowTurnId, workspacePath: workspace.workspacePath }); + const displayText = giteaDisplayMessage(task, event); + const workflowHooks = { + taskId: task.taskId, + kind: 'normal', + mcp: { host, accessToken: token }, + handleTurnStarted: ({ threadId, turnId }) => { + giteaWorkflowService.store.upsertSession({ ...workflowSession, sessionKey: task.sessionKey, ccwebSessionId: session.id, threadId }); + giteaWorkflowService.store.upsertTask({ + ...giteaWorkflowService.store.getTask(task.taskId), + threadId, + turnId, + turnState: 'running', + turnStartedAt: new Date().toISOString(), + turnUpdatedAt: new Date().toISOString(), + }); + }, + }; + const resultPromise = new Promise((resolve) => { + giteaWorkflowWaiters.set(session.id, { + task: { ...task, event }, + workflowTurnId, + markerTurnId: workflowTurnId, + markerTurnIds: [workflowTurnId], + giteaMcp, + giteaHost: host, + giteaToken: token, + resolve, + entry: null, + replyRetryCount: 0, + }); + }); + if (context.signal) { + context.signal.addEventListener('abort', () => { + handleCodexAppAbortSession(session.id, null); + }, { once: true }); + } + if (context.signal?.aborted) { + clearRepositoryActiveTask(); + throw Object.assign(new Error('任务已被管理员中止。'), { code: 'aborted' }); + } + try { + const dispatchResult = handleMessage(null, { type: 'message', sessionId: session.id, text: displayText, mode: 'yolo', agent: 'codexapp' }, { + runtimeText: prompt, + displayText, + giteaSource, + mcpContext: { giteaWorkflow: true, taskId: task.taskId, resourceKey: task.resourceKey }, + giteaWorkflow: workflowHooks, + }); + if (dispatchResult && dispatchResult.ok === false) { + throw Object.assign(new Error(dispatchResult.message || 'Gitea Codex App turn 未启动。'), { code: 'turn_start_rejected' }); + } + } catch (error) { + giteaWorkflowWaiters.delete(session.id); + clearRepositoryActiveTask(); + throw error; + } + const syncWorkspaceState = async () => { + // 每轮结束后重新探测工作区:若 Agent 产生未提交修改,将归属绑定到当前讨论, + // 使同一讨论可继续沿用,其他讨论进入 blocked_workspace;干净时才清除旧标记。 + try { + await giteaWorkspaceManager.inspectWorkspace(workspace.workspacePath, token); + const currentRepository = giteaWorkflowService.store.getRepository(task.repoKey); + if (currentRepository?.dirtySessionKey === task.sessionKey) { + giteaWorkflowService.store.upsertRepository({ + ...currentRepository, + status: 'active', + dirty: false, + dirtyReason: null, + dirtySessionKey: null, + }); + } + } catch (error) { + if (error?.code !== 'blocked_workspace') return; + const currentRepository = giteaWorkflowService.store.getRepository(task.repoKey); + if (currentRepository?.dirtySessionKey && currentRepository.dirtySessionKey !== task.sessionKey) return; + giteaWorkflowService.store.upsertRepository({ + ...(currentRepository || repository), + status: 'blocked', + dirty: true, + dirtyReason: error.message || '工作区存在未提交修改或冲突。', + dirtySessionKey: task.sessionKey, + }); + } + }; + return resultPromise + .finally(syncWorkspaceState) + .finally(clearRepositoryActiveTask); +}); + +// 队列层产生的异常/控制终态也必须回到 Gitea,避免用户只看到最初的“已接收”。 +const giteaWorkflowStatusLabels = { + blocked_workspace: (task) => `工作区阻塞:${task.errorMessage || '检测到未提交修改或冲突'}`, + failed: (task) => `任务失败:${task.errorMessage || task.errorCode || '未知错误'}`, + failed_reply: (task) => `回帖失败:${task.errorMessage || task.errorCode || 'MCP 和 REST 兜底均未成功'}`, + aborted: () => '已中止', + cancelled: () => '已取消排队任务', +}; +giteaWorkflowService.queue.on('updated', (task) => { + const label = giteaWorkflowStatusLabels[task?.state]; + if (!label) return; + const event = task.metadata?.event || task.event || null; + if (!event) return; + void postGiteaWorkflowStatus({ ...task, event }, label(task)); +}); +const giteaWebhookRoute = createGiteaWebhookRoute(giteaWebhookReceiver, { + path: process.env.CC_WEB_GITEA_WEBHOOK_PATH || '/api/gitea/webhook', +}); + const sessionSearchIndex = createSessionSearchIndex({ sessionsDir: SESSIONS_DIR, maxFileBytes: SESSION_LOAD_MAX_BYTES, @@ -764,6 +1413,7 @@ const activeProcesses = new Map(); // Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls } const activeCodexAppTurns = new Map(); +// Gitea 工作流任务等待 Codex App turn 完成:sessionId -> resolver metadata。 // Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled } const activeCodexAppGoalCommands = new Map(); @@ -804,6 +1454,8 @@ const pendingCrossConversationReplies = new Map(); const pendingCodexAppUserInputs = new Map(); // Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer } const pendingCodexAppApprovals = new Map(); +// Pending MCP elicitation requests: requestId -> { sessionId, params, resolve, timer } +const pendingCodexAppElicitations = new Map(); let taskBoardService = null; let taskBoardMcpHandlers = null; let taskBoardLifecycle = null; @@ -1056,6 +1708,14 @@ function commandExistsForMcp(command) { return false; } +function resolveGiteaMcpCommand() { + const configured = String(process.env.CC_WEB_GITEA_MCP_COMMAND || '').trim(); + if (configured) return configured; + const bundledName = process.platform === 'win32' ? 'gitea-mcp.exe' : 'gitea-mcp'; + const bundledPath = path.join(APP_DIR, 'bin', bundledName); + return fs.existsSync(bundledPath) ? bundledPath : 'gitea-mcp'; +} + function getProjectCodexConfigTomlPath(cwd) { const start = normalizeExistingDirPath(cwd); if (!start) return ''; @@ -3344,6 +4004,27 @@ function findLatestUserMessageIndex(messages) { return null; } +// Gitea 来源元数据只保留用户需要识别的资源信息,不把工作区、Token 或 +// 回执标识带入会话展示层。该结构同时用于会话列表和来源消息气泡。 +function normalizeGiteaSource(source) { + if (!source || typeof source !== 'object' || Array.isArray(source)) return null; + const repository = String(source.repository || source.repo || '').trim().slice(0, 240); + const resourceKind = String(source.resourceKind || source.kind || '').trim().toLowerCase(); + const normalizedKind = resourceKind === 'pull_request' || resourceKind === 'pr' ? 'pull_request' : 'issue'; + const parsedNumber = Number(source.number); + const number = Number.isSafeInteger(parsedNumber) && parsedNumber > 0 ? parsedNumber : null; + const title = String(source.title || '').replace(/\s+/g, ' ').trim().slice(0, 240); + if (!repository && !number && !title) return null; + return { + source: 'gitea', + label: 'Gitea', + repository, + resourceKind: normalizedKind, + number, + title, + }; +} + function normalizeSession(session) { if (!session || typeof session !== 'object') return session; session.agent = normalizeAgent(session.agent); @@ -3356,6 +4037,11 @@ function normalizeSession(session) { delete session.titleSource; } session.titleHistory = normalizeTitleHistory(session.titleHistory); + if (session.giteaSource) { + const normalizedGiteaSource = normalizeGiteaSource(session.giteaSource); + if (normalizedGiteaSource) session.giteaSource = normalizedGiteaSource; + else delete session.giteaSource; + } if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null; if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null; if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null; @@ -4204,6 +4890,7 @@ const SESSION_META_PREVIEW_TOP_LEVEL_KEYS = new Set([ 'hasUnread', 'agent', 'cwd', + 'giteaSource', ]); function isJsonWhitespace(ch) { @@ -4415,7 +5102,8 @@ function loadSessionMetaFromFile(filePath) { hasUnread: !!session.hasUnread, agent: getSessionAgent(session), cwd, - projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', + giteaSource: normalizeGiteaSource(session.giteaSource), + projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', codexAppGoalActive: isCodexThreadGoalActive(session.codexAppGoal), fileBytes: stat.size, oversized: stat.size > SESSION_LOAD_MAX_BYTES, @@ -4437,7 +5125,8 @@ function loadSessionMetaFromFile(filePath) { hasUnread: previewBooleanField(previewFields, 'hasUnread'), agent: normalizeAgent(previewStringField(previewFields, 'agent')), cwd, - projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', + giteaSource: normalizeGiteaSource(previewFields.giteaSource), + projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', codexAppGoalActive: false, fileBytes: stat.size, oversized: stat.size > SESSION_LOAD_MAX_BYTES, @@ -5487,6 +6176,7 @@ function sendSessionList(ws) { pinnedAt: meta.pinnedAt || null, titleSource: meta.titleSource || null, createdFromKind: meta.createdFromKind || null, + giteaSource: meta.giteaSource || null, hasUnread: !!meta.hasUnread, agent: normalizeAgent(meta.agent), cwd: meta.cwd || '', @@ -5696,6 +6386,7 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { status, updatedAt: meta.updated || meta.created || null, pinnedAt: meta.pinnedAt || null, + giteaSource: meta.giteaSource || null, cwd: meta.cwd || '', projectName: meta.projectName || '', isCurrent: meta.id === sourceSessionId, @@ -7493,6 +8184,17 @@ function recoverProcesses() { const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname.startsWith('/api/gitea-workflow/')) { + return handleGiteaWorkflowManagementApi(req, res, url, giteaWorkflowManagement, { + authenticate(request) { + const token = extractBearerToken(request); + return !!token && activeTokens.has(token); + }, + }); + } + + if (giteaWebhookRoute(req, res, url)) return; + if (url.pathname === '/api/internal/mcp/stream') { return handleSharedMcpHttpApi(req, res, url); } @@ -7813,6 +8515,9 @@ wss.on('connection', (ws, req) => { case 'codex_app_user_input_response': handleCodexAppUserInputResponse(ws, msg); break; + case 'codex_app_elicitation_response': + handleCodexAppElicitationResponse(ws, msg); + break; case 'ccweb_prompt_user_response': handleCcwebPromptUserResponse(ws, msg); break; @@ -8965,6 +9670,7 @@ function publicTitleMetadata(session) { return { titleSource: String(session?.titleSource || '').trim().toLowerCase() || null, createdFromKind: sessionCreatedFromKind(session) || null, + giteaSource: normalizeGiteaSource(session?.giteaSource), }; } @@ -9161,6 +9867,7 @@ function createPersistentConversationSession(args = {}, options = {}) { options.inheritSourceModel === true ? sourceSession : null, ), permissionMode: modeResult.mode, + giteaSource: normalizeGiteaSource(args.giteaSource), totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, messages: initialMessages, @@ -9864,6 +10571,7 @@ function handleMessage(ws, msg, options = {}) { }; const textValue = typeof text === 'string' ? text : ''; let runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue; + const displayTextValue = typeof options.displayText === 'string' ? options.displayText : textValue; const attachments = Array.isArray(msg.attachments) ? msg.attachments : []; const normalizedText = textValue.trim(); let normalizedRuntimeText = runtimeTextValue.trim(); @@ -9980,10 +10688,12 @@ function handleMessage(ws, msg, options = {}) { if (!hideInHistory) { persistedUserMessage = { role: 'user', - content: textValue, + content: displayTextValue, attachments: savedAttachments, timestamp: new Date().toISOString(), }; + const giteaSource = normalizeGiteaSource(options.giteaSource); + if (giteaSource) persistedUserMessage.giteaSource = giteaSource; if (options.crossConversation) { persistedUserMessage.crossConversation = options.crossConversation; } @@ -10029,6 +10739,8 @@ function handleMessage(ws, msg, options = {}) { if (isCodexAppSession(session)) { return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, { mcpContext: options.mcpContext || {}, + giteaWorkflow: options.giteaWorkflow || null, + giteaMcp: options.giteaMcp || null, crossConversation: options.crossConversation || null, codexRetry: options.codexRetry || null, }); @@ -10373,6 +11085,7 @@ function isCodexAppAdoptableRuntimeMethod(method) { || method === 'item/permissions/requestApproval' || method === 'item/tool/requestApproval' || method === 'item/tool/requestUserInput' + || method === 'mcpServer/elicitation/request' || method === 'item/tool/call'; } @@ -11274,6 +11987,88 @@ function resolvePendingCodexAppUserInputsForSession(sessionId) { } } +function codexAppElicitationResponse(action = 'cancel', content = null, meta = null) { + const normalizedAction = ['accept', 'decline', 'cancel'].includes(String(action || '').trim()) + ? String(action).trim() + : 'cancel'; + return { + action: normalizedAction, + content: normalizedAction === 'accept' && content && typeof content === 'object' ? content : null, + _meta: meta && typeof meta === 'object' ? meta : null, + }; +} + +function requestCodexAppElicitation(routed, params = {}) { + const targetWs = routed?.entry?.ws || findViewingSessionWs(routed?.sessionId); + if (!routed?.sessionId || !targetWs) { + return Promise.resolve(codexAppElicitationResponse('cancel')); + } + + const requestId = crypto.randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingCodexAppElicitations.delete(requestId); + resolve(codexAppElicitationResponse('cancel')); + }, 10 * 60 * 1000); + + pendingCodexAppElicitations.set(requestId, { + sessionId: routed.sessionId, + params, + resolve, + timer, + }); + + wsSend(targetWs, { + type: 'codex_app_elicitation_request', + sessionId: routed.sessionId, + requestId, + mode: String(params.mode || 'form'), + serverName: String(params.serverName || '').trim(), + turnId: params.turnId || null, + message: String(params.message || ''), + requestedSchema: params.requestedSchema || null, + url: params.url || null, + elicitationId: params.elicitationId || null, + _meta: params._meta || null, + }); + }); +} + +function handleCodexAppElicitationResponse(ws, msg = {}) { + const requestId = String(msg.requestId || '').trim(); + const pending = pendingCodexAppElicitations.get(requestId); + if (!pending) { + wsSend(ws, { type: 'error', code: 'codexapp_elicitation_not_found', message: 'MCP elicitation 请求不存在或已超时。' }); + return; + } + + pendingCodexAppElicitations.delete(requestId); + clearTimeout(pending.timer); + const action = String(msg.action || 'cancel').trim(); + const content = msg.content && typeof msg.content === 'object' ? msg.content : null; + const result = codexAppElicitationResponse(action, content, msg._meta || null); + wsSend(ws, { + type: 'system_message', + sessionId: pending.sessionId, + tone: result.action === 'accept' ? 'info' : 'warning', + transient: true, + autoDismissMs: 5000, + message: result.action === 'accept' + ? '已提交 MCP elicitation 信息。' + : (result.action === 'decline' ? '已拒绝 MCP elicitation 请求。' : '已取消 MCP elicitation 请求。'), + }); + pending.resolve(result); +} + +function resolvePendingCodexAppElicitationsForSession(sessionId) { + for (const [requestId, pending] of pendingCodexAppElicitations) { + if (pending.sessionId !== sessionId) continue; + pendingCodexAppElicitations.delete(requestId); + clearTimeout(pending.timer); + pending.resolve(codexAppElicitationResponse('cancel')); + } +} + function handleCcwebPromptUserResponse(ws, msg = {}) { const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || ''); const promptId = String(msg.promptId || '').trim(); @@ -11573,6 +12368,10 @@ function handleCodexAppServerRequest(request) { const method = request?.method || ''; const params = request?.params || {}; const routed = findCodexAppRouteByRuntime(params, method); + if (method === 'item/tool/requestUserInput' && routed?.entry?.giteaWorkflow) { + routed.entry.waitingUserRequested = true; + routed.entry.turnStatus = 'waiting_user'; + } const dynamicToolResponse = method === 'item/tool/call' ? handleCodexAppDynamicToolCall(routed, params) : null; @@ -11580,7 +12379,8 @@ function handleCodexAppServerRequest(request) { || method === 'item/fileChange/requestApproval' || method === 'item/permissions/requestApproval' || method === 'item/tool/requestApproval'; - if (!dynamicToolResponse && !isApprovalRequest && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) { + const isElicitationRequest = method === 'mcpServer/elicitation/request'; + if (!dynamicToolResponse && !isApprovalRequest && !isElicitationRequest && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) { wsSend(routed.entry.ws, { type: 'system_message', sessionId: routed.sessionId, @@ -11602,6 +12402,8 @@ function handleCodexAppServerRequest(request) { }; case 'item/tool/requestUserInput': return requestCodexAppUserInput(routed, params); + case 'mcpServer/elicitation/request': + return requestCodexAppElicitation(routed, params); default: throw new Error(`cc-web 暂不支持 Codex app-server 请求: ${method}`); } @@ -11698,6 +12500,16 @@ function codexAppThreadConfig(session, options = {}) { if (!item?.server || !item?.config) continue; config[`mcp_servers.${item.server}`] = item.config; } + // Gitea Workflow 通过线程上下文注入官方 gitea-mcp,不使用进程级来源环境。 + const giteaMcp = options.giteaWorkflow?.mcp || options.giteaMcp || null; + if (giteaMcp?.type === 'stdio' && giteaMcp.command) { + config['mcp_servers.gitea'] = { ...giteaMcp }; + } else if (giteaMcp?.host && (giteaMcp.accessToken || giteaMcp.token)) { + Object.assign(config, mergeGiteaThreadConfig(config, { + ...giteaMcp, + cwd: session?.cwd || options.cwd || getDefaultSessionCwd(), + })); + } return config; } @@ -11928,6 +12740,10 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment toolOutputDeltas: new Map(), agentMessageItems: new Map(), mcpContext: options.mcpContext || {}, + giteaWorkflow: options.giteaWorkflow || null, + giteaMcp: options.giteaMcp || options.giteaWorkflow?.mcp || null, + giteaWorkflowTaskId: options.giteaWorkflow?.taskId || null, + giteaWorkflowKind: options.giteaWorkflow?.kind || 'normal', codexRetry, lastUsage: null, lastError: null, @@ -11977,7 +12793,11 @@ async function startCodexAppTurn(sessionId, input) { || currentThreadId || null; let threadId = expectedThreadId || currentThreadId; - const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} }); + const threadParams = codexAppThreadParams(session, { + mcpContext: entry.mcpContext || {}, + giteaWorkflow: entry.giteaWorkflow || null, + giteaMcp: entry.giteaMcp || null, + }); if (threadId) { const requestedThreadId = threadId; const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000); @@ -12010,6 +12830,18 @@ async function startCodexAppTurn(sessionId, input) { const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000); if (turn?.turn?.id) entry.turnId = turn.turn.id; + if (entry.giteaWorkflow?.handleTurnStarted && entry.giteaWorkflowTaskId && entry.turnId) { + try { + await Promise.resolve(entry.giteaWorkflow.handleTurnStarted({ + taskId: entry.giteaWorkflowTaskId, + threadId, + turnId: entry.turnId, + kind: entry.giteaWorkflowKind || 'normal', + })); + } catch (error) { + plog('WARN', 'gitea_workflow_turn_started_hook_failed', { error: error?.message || String(error || '') }); + } + } if (turn?.turn?.id) { dispatchTaskBoardLifecycle(sessionId, { type: TASK_BOARD_LIFECYCLE_EVENTS.TURN_STARTED, @@ -12026,6 +12858,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { if (!entry) return; resolvePendingCodexAppUserInputsForSession(sessionId); resolvePendingCodexAppApprovalsForSession(sessionId); + resolvePendingCodexAppElicitationsForSession(sessionId); const explicitError = options.error || null; const rawError = explicitError || entry.lastError || null; @@ -12035,6 +12868,18 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { const session = loadSession(sessionId); const turnKey = codexAppTurnKey(sessionId, entry); + if (entry.giteaWorkflow?.handleTurnCompleted && entry.giteaWorkflowTaskId) { + void Promise.resolve(entry.giteaWorkflow.handleTurnCompleted({ + taskId: entry.giteaWorkflowTaskId, + threadId: entry.threadId || extractGiteaWorkflowThreadId(options) || null, + turnId: entry.turnId || extractGiteaWorkflowTurnId(options) || null, + status: options.interrupted || entry.userAborted ? 'interrupted' : (entry.lastError ? 'failed' : 'completed'), + finalText: entry.fullText || '', + notification: options.notification || null, + })).catch((error) => { + plog('WARN', 'gitea_workflow_turn_completed_hook_failed', { error: error?.message || String(error || '') }); + }); + } const assistantContent = truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS); const assistantToolCalls = sanitizeToolCallsForPersist(entry.toolCalls || [], { maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE, @@ -12086,6 +12931,14 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { activeCodexAppTurns.delete(sessionId); cleanupCodexAppTurnState(sessionId, entry); + const workflowWaiter = giteaWorkflowWaiters.get(sessionId); + if (workflowWaiter && !workflowWaiter.entry) { + workflowWaiter.entry = entry; + void settleGiteaWorkflowTurn(sessionId, entry, session).catch((error) => { + giteaWorkflowWaiters.delete(sessionId); + workflowWaiter.resolve({ state: 'failed_reply', errorCode: 'reply_settlement_failed', errorMessage: error?.message || String(error || '') }); + }); + } const goalActive = isCodexAppGoalActive(sessionId); dispatchTaskBoardLifecycle(sessionId, { type: completionError @@ -12948,6 +13801,8 @@ function handleListCwdSuggestions(ws) { // === Startup === loadCrossConversationReplies(); recoverProcesses(); +// 先恢复 Codex App 运行态,再领取 Gitea 队列,避免重启窗口产生重复 turn。 +giteaWorkflowService.queue.recover(); reconcilePendingCrossConversationReplies(); // Periodic heartbeat: log active processes status every 60s