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,314 @@
'use strict';
/**
* Gitea Workflow 核心编排服务。
*
* 该服务是 Webhook/管理 API 与领域存储、队列之间的适配层。server.js
* 只需在路由中注入本服务实例,即可逐步接入,不需要复制状态机逻辑。
*/
const crypto = require('crypto');
const domain = require('./gitea-workflow-domain');
const { createGiteaWorkflowStore } = require('./gitea-workflow-store');
const { createGiteaWorkflowQueue } = require('./gitea-workflow-queue');
const {
normalizeWebhookEvent,
verifyWebhookSignature,
} = require('./gitea-webhook');
function safeText(value, max = 1000) {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
function header(headers, names) {
const source = headers || {};
for (const wanted of names) {
for (const [key, value] of Object.entries(source)) {
if (key.toLowerCase() === wanted.toLowerCase()) return Array.isArray(value) ? value[0] : value;
}
}
return '';
}
function buildGiteaMcpConfig(config, options = {}) {
const normalized = domain.normalizeConfig(config || {});
const host = safeText(options.host || normalized.gitea.host, 500);
const token = String(options.token ?? normalized.gitea.token ?? '');
const command = safeText(options.command || normalized.gitea.mcpCommand, 200) || 'gitea-mcp';
const args = Array.isArray(options.args) ? options.args.slice() : ['-t', 'stdio'];
if (host && !args.includes('-H')) args.push('-H', host);
return {
type: 'stdio',
command,
args,
env: {
...(options.env || {}),
GITEA_HOST: host,
GITEA_ACCESS_TOKEN: token,
GITEA_TOKEN: token,
},
};
}
function parseBody(rawBody) {
try {
return JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody || ''));
} catch {
const error = new Error('Webhook 请求体不是有效 JSON');
error.code = 'invalid_json';
throw error;
}
}
class GiteaWorkflowService {
constructor(options = {}) {
this.config = domain.normalizeConfig(options.config || options, options.env || process.env);
this.store = options.store || createGiteaWorkflowStore({
filePath: options.statePath || options.filePath,
now: options.now,
});
this.clock = typeof options.now === 'function' ? options.now : Date.now;
this.runner = typeof options.runner === 'function' ? options.runner : null;
this.queue = options.queue || createGiteaWorkflowQueue({
store: this.store,
runner: this.runner || (async (task) => ({ state: domain.TASK_STATES.FAILED, errorCode: 'runner_not_configured', errorMessage: `未配置 Codex runner${task.taskId})。` })),
autoRecover: options.autoRecover !== false,
now: this.clock,
});
this.queue.on('updated', (task) => this.emitTaskAudit(task));
}
emitTaskAudit(task) {
if (!task || !task.taskId) return null;
return this.store.appendAudit({
taskId: task.taskId,
sessionKey: task.sessionKey,
repoKey: task.repoKey,
action: 'task.updated',
toState: task.state,
deliveryId: task.deliveryId,
turnId: task.turnId,
errorCode: task.errorCode,
});
}
verify(rawBody, headers = {}) {
const secret = this.config.gitea.webhookSecret;
// 内部部署可以不配置 Secret配置后仍严格执行 HMAC 验签。
if (!secret) return true;
const signature = header(headers, ['x-gitea-signature', 'x-hub-signature-256', 'x-signature']);
return Boolean(secret && verifyWebhookSignature(rawBody, signature, secret));
}
async ingestWebhook({ rawBody, headers = {} } = {}) {
if (!this.verify(rawBody, headers)) return { statusCode: 401, payload: { ok: false, code: 'invalid_signature' } };
let payload;
try { payload = parseBody(rawBody); } catch (error) { return { statusCode: 400, payload: { ok: false, code: error.code } }; }
const deliveryId = safeText(header(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || payload.delivery_id, 180);
if (!deliveryId) return { statusCode: 400, payload: { ok: false, code: 'missing_delivery_id' } };
const deliveryKey = `${this.config.instanceId}:${deliveryId}`;
const claim = this.store.claimDelivery(deliveryKey, { deliveryId, metadata: { event: header(headers, ['x-gitea-event']) } });
if (claim.duplicate) {
return { statusCode: 200, payload: { ok: true, duplicate: true, deliveryId, taskId: claim.record.taskId }, record: claim.record };
}
const event = normalizeWebhookEvent({
payload,
headers,
instanceId: this.config.instanceId,
botIdentity: { login: this.config.botLogin, id: this.config.botId },
});
if (event.ignored) {
this.store.updateDelivery(deliveryKey, { status: 'ignored', reason: event.reason });
this.store.appendAudit({ action: 'webhook.ignored', actor: 'gitea', deliveryId, metadata: { reason: event.reason, eventName: event.eventName } });
return { statusCode: 200, payload: { ok: true, ignored: true, reason: event.reason, deliveryId }, event };
}
const repository = this.store.upsertRepository({
instanceId: this.config.instanceId,
owner: event.repository.owner,
name: event.repository.name,
cloneUrl: event.repository.cloneUrl,
defaultBranch: this.config.gitea.defaultBranchOverride || event.repository.defaultBranch,
});
if (repository.enabled === false || repository.status === 'disabled') {
this.store.updateDelivery(deliveryKey, { status: 'rejected', reason: 'repository_disabled' });
this.store.appendAudit({ action: 'webhook.rejected', actor: 'gitea', deliveryId, repoKey: repository.key, errorCode: 'repository_disabled' });
return { statusCode: 200, payload: { ok: true, rejected: true, reason: 'repository_disabled', deliveryId } };
}
const sessionInput = {
instanceId: this.config.instanceId,
owner: event.repository.owner,
repo: event.repository.name,
kind: event.resource.kind,
number: event.resource.number,
};
const session = this.store.upsertSession(sessionInput);
const task = this.store.createTask({
taskId: `gitea-task-${crypto.createHash('sha256').update(deliveryKey).digest('hex').slice(0, 24)}`,
deliveryKey, deliveryId, repoKey: repository.key, resourceKey: session.resourceKey,
sessionKey: session.sessionKey, commentId: event.comment.id || null,
actor: event.comment.author.login || null, instruction: event.mention.instruction,
state: domain.TASK_STATES.RECEIVED,
// 保留经过规范化的仓库/资源上下文,供工作区与 Codex runner 使用;其中不含 Token/Secret。
metadata: { eventName: event.eventName, resource: event.resource, event },
});
this.store.updateDelivery(deliveryKey, { status: 'accepted', taskId: task.taskId });
this.store.appendAudit({ action: 'webhook.accepted', actor: 'gitea', deliveryId, taskId: task.taskId, sessionKey: task.sessionKey, repoKey: task.repoKey, fromState: domain.TASK_STATES.RECEIVED, toState: domain.TASK_STATES.QUEUED, commentId: task.commentId });
if (this.runner) this.queue.setRunner(task.taskId, this.runner);
this.queue.enqueue(task.taskId).catch(() => undefined);
return { statusCode: 202, payload: { ok: true, accepted: true, deliveryId, taskId: task.taskId, sessionKey: task.sessionKey }, task, event };
}
/**
* 接收已经由独立 Webhook receiver 验签和规范化的任务,供 server.js
* 保持最小挂接点。该方法仍在本地 store 中执行 delivery 幂等。
*/
enqueueNormalizedTask(input = {}) {
const event = input.event || input;
const deliveryId = safeText(input.deliveryId || event.deliveryId, 180);
const deliveryKey = safeText(input.deliveryKey || `${this.config.instanceId}:${deliveryId}`, 240);
const claim = this.store.claimDelivery(deliveryKey, { deliveryId, taskId: input.taskId || null, metadata: { source: 'normalized_receiver' } });
if (claim.duplicate) {
return { ok: true, duplicate: true, task: claim.record.taskId ? this.store.getTask(claim.record.taskId) : null, delivery: claim.record };
}
const repository = this.store.upsertRepository({
instanceId: this.config.instanceId,
owner: event.repository?.owner,
name: event.repository?.name,
cloneUrl: event.repository?.cloneUrl,
defaultBranch: this.config.gitea.defaultBranchOverride || event.repository?.defaultBranch,
});
if (repository.enabled === false || repository.status === 'disabled') {
this.store.updateDelivery(deliveryKey, { status: 'rejected', reason: 'repository_disabled' });
this.store.appendAudit({ action: 'webhook.rejected', actor: 'gitea', deliveryId, repoKey: repository.key, errorCode: 'repository_disabled' });
return { ok: false, duplicate: false, rejected: true, code: 'repository_disabled', repository };
}
const resource = event.resource || {};
const session = this.store.upsertSession({ instanceId: this.config.instanceId, owner: repository.owner, repo: repository.name, kind: resource.kind, number: resource.number });
const task = this.store.createTask({
taskId: input.taskId || `gitea-task-${crypto.createHash('sha256').update(deliveryKey).digest('hex').slice(0, 24)}`,
deliveryKey, deliveryId, repoKey: repository.key, resourceKey: session.resourceKey, sessionKey: session.sessionKey,
commentId: event.comment?.id || null, actor: event.comment?.author?.login || null,
instruction: event.mention?.instruction || event.comment?.body || '', metadata: { eventName: event.eventName, resource, event },
state: domain.TASK_STATES.RECEIVED,
});
this.store.updateDelivery(deliveryKey, { status: 'accepted', taskId: task.taskId });
this.store.appendAudit({ action: 'webhook.accepted', actor: 'gitea', deliveryId, taskId: task.taskId, repoKey: task.repoKey, sessionKey: task.sessionKey, commentId: task.commentId });
if (this.runner) this.queue.setRunner(task.taskId, this.runner);
this.queue.enqueue(task.taskId).catch(() => undefined);
return { ok: true, duplicate: false, task, repository, session };
}
/** 运行时挂接 Codex/工作区 runner并立即尝试领取队列。 */
setRunner(runner) {
if (typeof runner !== 'function') throw new TypeError('runner 必须是函数');
this.runner = runner;
this.queue.defaultRunner = runner;
for (const task of this.store.listTasks({ state: domain.TASK_STATES.QUEUED })) this.queue.setRunner(task.taskId, runner);
this.queue.drain();
return this;
}
/**
* 在进程内更新管理页保存的连接配置,无需重启服务。
* Secret 只从管理适配器传入内存配置,不会写入任务、日志或公开响应。
*/
updateConfig(input = {}) {
const current = this.config || {};
const currentGitea = current.gitea || {};
this.config = domain.normalizeConfig({
...current,
instanceId: input.instanceId ?? current.instanceId,
botLogin: input.botLogin ?? current.botLogin,
botId: input.botId ?? current.botId,
workspaceRoot: input.workspaceRoot ?? current.workspaceRoot,
defaultBranchOverride: input.defaultBranchOverride ?? currentGitea.defaultBranchOverride,
gitea: {
...currentGitea,
...(input.gitea || {}),
host: input.host ?? input.gitea?.host ?? currentGitea.host,
token: input.botToken ?? input.gitea?.token ?? currentGitea.token,
webhookSecret: input.webhookSecret ?? input.gitea?.webhookSecret ?? currentGitea.webhookSecret,
defaultBranchOverride: input.defaultBranchOverride ?? input.gitea?.defaultBranchOverride ?? currentGitea.defaultBranchOverride,
},
});
return this.config;
}
getOverview() {
const tasks = this.store.listTasks();
const repositories = this.store.listRepositories();
return {
ok: true,
available: true,
generatedAt: domain.iso(this.clock),
control: this.store.getControl(),
summary: {
running: tasks.filter((task) => domain.RUNNING_STATES.has(task.state)).length,
queued: tasks.filter((task) => task.state === domain.TASK_STATES.QUEUED || task.state === domain.TASK_STATES.RETRY_WAIT).length,
repositories: repositories.length,
blocked: repositories.filter((repo) => repo.dirty || repo.status === 'blocked').length,
},
repositories,
tasks,
audits: this.store.listAudits().slice(-200),
queue: this.queue.getStatus(),
};
}
listRepositories(query = {}) { return this.store.listRepositories(query); }
listTasks(query = {}) { return this.store.listTasks(query); }
getTask(taskId) { return this.store.getTask(taskId); }
listSessions(query = {}) { return this.store.listSessions(query); }
listTurns(query = {}) { return this.store.listTurns(query); }
listAudits(query = {}) { return this.store.listAudits(query); }
listAudit(query = {}) { return this.listAudits(query); }
pause(input = {}) { const result = this.queue.pause(input); this.store.appendAudit({ action: 'admin.pause', actor: input.actor || 'admin', metadata: { reason: input.reason || null } }); return result; }
resume(input = {}) { const result = this.queue.resume(input); this.store.appendAudit({ action: 'admin.resume', actor: input.actor || 'admin', metadata: { reason: input.reason || null } }); return result; }
setRepositoryEnabled(repoKey, enabled, input = {}) {
const repository = this.store.getRepository(repoKey);
if (!repository) return { ok: false, code: 'repository_not_found' };
const updated = this.store.upsertRepository({ ...repository, enabled: Boolean(enabled), status: enabled ? 'active' : 'disabled' });
this.store.appendAudit({ action: enabled ? 'admin.repository_enabled' : 'admin.repository_disabled', actor: input.actor || 'admin', repoKey, metadata: { reason: input.reason || null } });
if (enabled) {
for (const task of this.store.listTasks({ repoKey, state: domain.TASK_STATES.BLOCKED_WORKSPACE })) {
try { this.queue.enqueue(task.taskId); } catch {}
}
}
return { ok: true, repository: updated };
}
disableRepository(input = {}) { return this.setRepositoryEnabled(input.repoKey, false, input); }
enableRepository(input = {}) { return this.setRepositoryEnabled(input.repoKey, true, input); }
cancelTask(input = {}) { return this.queue.cancelQueued(input.taskId || input.id, input); }
abortTask(input = {}) { return this.queue.abortRunning(input.taskId || input.id, input); }
abortTurn(input = {}) { return this.abortTask(input); }
buildThreadConfig(options = {}) {
const session = options.session || {};
const settings = {
mode: 'yolo',
...(this.config.codex.model ? { model: this.config.codex.model } : {}),
...(this.config.codex.reasoningEffort ? { reasoning_effort: this.config.codex.reasoningEffort } : {}),
...(this.config.codex.developerInstructions ? { developer_instructions: this.config.codex.developerInstructions } : {}),
};
return {
cwd: options.cwd || null,
mcp_servers: {
gitea: buildGiteaMcpConfig(this.config, options),
},
collaborationMode: { settings },
sessionKey: session.sessionKey || null,
};
}
publicConfig() { return domain.publicConfig(this.config); }
}
function createGiteaWorkflowService(options) { return new GiteaWorkflowService(options); }
module.exports = {
GiteaWorkflowService,
buildGiteaMcpConfig,
createGiteaWorkflowService,
parseBody,
};