'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, };