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

315 lines
15 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 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,
};