Files
cc-web/lib/gitea-workflow-codex.js

639 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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('<', '&lt;')
.replaceAll('>', '&gt;');
}
function unescapeMarkerValue(value) {
return String(value ?? '')
.replaceAll('&quot;', '"')
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&');
}
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,
};