639 lines
26 KiB
JavaScript
639 lines
26 KiB
JavaScript
'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 `<!-- ${WORKFLOW_MARKER_NAME} v="${WORKFLOW_MARKER_VERSION}" taskId="${escapeMarkerValue(taskId)}" turnId="${escapeMarkerValue(turnId)}" resourceKey="${escapeMarkerValue(resourceKey)}" kind="${escapeMarkerValue(kind)}" -->`;
|
||
}
|
||
|
||
function parseWorkflowMarker(body) {
|
||
const text = String(body || '');
|
||
const markerMatch = text.match(new RegExp(`<!--\\s*${WORKFLOW_MARKER_NAME}\\b([\\s\\S]*?)-->`, '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,
|
||
};
|