feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

View File

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